From 9a9f3d8d9e8294fd3a579289cf1496629b02d2bf Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 2 Jun 2021 07:24:04 -0700 Subject: [PATCH 001/364] [webview_flutter] Add iOS integration tests (#3995) --- packages/webview_flutter/CHANGELOG.md | 4 + packages/webview_flutter/example/ios/Podfile | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 177 +++++++++++++----- .../xcshareddata/xcschemes/Runner.xcscheme | 10 + .../xcschemes/RunnerUITests.xcscheme | 52 ----- .../example/ios/RunnerTests/Info.plist | 22 +++ .../ios/RunnerUITests/FLTWebViewUITests.m | 101 ++++++++++ 7 files changed, 271 insertions(+), 97 deletions(-) delete mode 100644 packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/RunnerUITests.xcscheme create mode 100644 packages/webview_flutter/example/ios/RunnerTests/Info.plist create mode 100644 packages/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 30f34ac6b490..9167f9240044 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add iOS UI integration test target. + ## 2.0.8 * Migrate maven repository from jcenter to mavenCentral. diff --git a/packages/webview_flutter/example/ios/Podfile b/packages/webview_flutter/example/ios/Podfile index ce7f8a5df02b..66509fcae284 100644 --- a/packages/webview_flutter/example/ios/Podfile +++ b/packages/webview_flutter/example/ios/Podfile @@ -30,7 +30,7 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerUITests' do + target 'RunnerTests' do inherit! :search_paths # Matches test_spec dependency. diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 30ce866bcf73..e65843b137b0 100644 --- a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -17,7 +17,8 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 9D26F6F82D91F92CC095EBA9 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B845D8FBDE0AAD6BE1A0386 /* libPods-Runner.a */; }; - EE189BB43C38EE5DE05135A7 /* libPods-RunnerUITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EBE6A98F0F7B17A8E813670 /* libPods-RunnerUITests.a */; }; + D9A9D48F1A75E5C682944DDD /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 27CC950C9005575711528C12 /* libPods-RunnerTests.a */; }; + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F76266057800028CB91 /* FLTWebViewUITests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -28,6 +29,13 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + F7151F79266057800028CB91 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -44,14 +52,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 06E410020C7D35382771541C /* Pods-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.release.xcconfig"; sourceTree = ""; }; - 0EBE6A98F0F7B17A8E813670 /* libPods-RunnerUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 127772EEA7782174BE0D74B5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 27CC950C9005575711528C12 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FLTWKNavigationDelegateTests.m; path = ../../../ios/Tests/FLTWKNavigationDelegateTests.m; sourceTree = ""; }; - 68BDCAE923C3F7CB00D9C032 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 68BDCAED23C3F7CB00D9C032 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FLTWebViewTests.m; path = ../../../ios/Tests/FLTWebViewTests.m; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -67,7 +74,11 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C475C484BD510DD9CB2E403C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - DA434001E038D9F8CFB0EDEC /* Pods-RunnerUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.debug.xcconfig"; sourceTree = ""; }; + E14113434CCE6D3186B5CBC3 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F674B2A05DAC369B4FF27850 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F7151F74266057800028CB91 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F7151F76266057800028CB91 /* FLTWebViewUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewUITests.m; sourceTree = ""; }; + F7151F78266057800028CB91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,7 +86,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - EE189BB43C38EE5DE05135A7 /* libPods-RunnerUITests.a in Frameworks */, + D9A9D48F1A75E5C682944DDD /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -87,17 +98,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7151F71266057800028CB91 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 68BDCAEA23C3F7CB00D9C032 /* RunnerUITests */ = { + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */ = { isa = PBXGroup; children = ( 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */, 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */, 68BDCAED23C3F7CB00D9C032 /* Info.plist */, ); - path = RunnerUITests; + path = RunnerTests; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { @@ -116,7 +134,8 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - 68BDCAEA23C3F7CB00D9C032 /* RunnerUITests */, + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */, + F7151F75266057800028CB91 /* RunnerUITests */, 97C146EF1CF9000F007C117D /* Products */, C6FFB52F5C2B8A41A7E39DE2 /* Pods */, B6736FC417BDCCDA377E779D /* Frameworks */, @@ -127,7 +146,8 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 68BDCAE923C3F7CB00D9C032 /* RunnerUITests.xctest */, + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */, + F7151F74266057800028CB91 /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -160,7 +180,7 @@ isa = PBXGroup; children = ( 8B845D8FBDE0AAD6BE1A0386 /* libPods-Runner.a */, - 0EBE6A98F0F7B17A8E813670 /* libPods-RunnerUITests.a */, + 27CC950C9005575711528C12 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -170,18 +190,27 @@ children = ( 127772EEA7782174BE0D74B5 /* Pods-Runner.debug.xcconfig */, C475C484BD510DD9CB2E403C /* Pods-Runner.release.xcconfig */, - DA434001E038D9F8CFB0EDEC /* Pods-RunnerUITests.debug.xcconfig */, - 06E410020C7D35382771541C /* Pods-RunnerUITests.release.xcconfig */, + F674B2A05DAC369B4FF27850 /* Pods-RunnerTests.debug.xcconfig */, + E14113434CCE6D3186B5CBC3 /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; }; + F7151F75266057800028CB91 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + F7151F76266057800028CB91 /* FLTWebViewUITests.m */, + F7151F78266057800028CB91 /* Info.plist */, + ); + path = RunnerUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 68BDCAE823C3F7CB00D9C032 /* RunnerUITests */ = { + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */ = { isa = PBXNativeTarget; - buildConfigurationList = 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildConfigurationList = 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( 53FD4CBDD9756D74B5A3B4C1 /* [CP] Check Pods Manifest.lock */, 68BDCAE523C3F7CB00D9C032 /* Sources */, @@ -193,9 +222,9 @@ dependencies = ( 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */, ); - name = RunnerUITests; + name = RunnerTests; productName = webview_flutter_exampleTests; - productReference = 68BDCAE923C3F7CB00D9C032 /* RunnerUITests.xctest */; + productReference = 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { @@ -219,6 +248,24 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F7151F73266057800028CB91 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + F7151F70266057800028CB91 /* Sources */, + F7151F71266057800028CB91 /* Frameworks */, + F7151F72266057800028CB91 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F7151F7A266057800028CB91 /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = F7151F74266057800028CB91 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -235,6 +282,11 @@ 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; }; + F7151F73266057800028CB91 = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -251,7 +303,8 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 68BDCAE823C3F7CB00D9C032 /* RunnerUITests */, + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */, + F7151F73266057800028CB91 /* RunnerUITests */, ); }; /* End PBXProject section */ @@ -275,6 +328,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7151F72266057800028CB91 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -307,7 +367,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerUITests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -326,7 +386,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; B71376B4FB8384EF9D5F3F84 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -368,6 +428,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7151F70266057800028CB91 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -376,6 +444,11 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */; }; + F7151F7A266057800028CB91 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F7151F79266057800028CB91 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -400,47 +473,30 @@ /* Begin XCBuildConfiguration section */ 68BDCAF023C3F7CB00D9C032 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DA434001E038D9F8CFB0EDEC /* Pods-RunnerUITests.debug.xcconfig */; + baseConfigurationReference = F674B2A05DAC369B4FF27850 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = RunnerUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.google.webview-flutter-exampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; name = Debug; }; 68BDCAF123C3F7CB00D9C032 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 06E410020C7D35382771541C /* Pods-RunnerUITests.release.xcconfig */; + baseConfigurationReference = E14113434CCE6D3186B5CBC3 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = RunnerUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.google.webview-flutter-exampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; name = Release; @@ -557,7 +613,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -581,7 +636,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -599,10 +653,36 @@ }; name = Release; }; + F7151F7C266057800028CB91 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + F7151F7D266057800028CB91 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 68BDCAF023C3F7CB00D9C032 /* Debug */, @@ -629,6 +709,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7151F7C266057800028CB91 /* Debug */, + F7151F7D266057800028CB91 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index bcb1de689917..d7453a8ce862 100644 --- a/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -42,6 +42,16 @@ + + + + diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/RunnerUITests.xcscheme b/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/RunnerUITests.xcscheme deleted file mode 100644 index 917be4f45bfa..000000000000 --- a/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/RunnerUITests.xcscheme +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/webview_flutter/example/ios/RunnerTests/Info.plist b/packages/webview_flutter/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m b/packages/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m new file mode 100644 index 000000000000..d193be745972 --- /dev/null +++ b/packages/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m @@ -0,0 +1,101 @@ +// Copyright 2013 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 XCTest; +@import os.log; + +@interface FLTWebViewUITests : XCTestCase +@property(nonatomic, strong) XCUIApplication* app; +@end + +@implementation FLTWebViewUITests + +- (void)setUp { + self.continueAfterFailure = NO; + + self.app = [[XCUIApplication alloc] init]; + [self.app launch]; +} + +- (void)testUserAgent { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* userAgent = app.buttons[@"Show user agent"]; + if (![userAgent waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Show user agent"); + } + NSPredicate* userAgentPredicate = + [NSPredicate predicateWithFormat:@"label BEGINSWITH 'User Agent: Mozilla/5.0 (iPhone; '"]; + XCUIElement* userAgentPopUp = [app.otherElements elementMatchingPredicate:userAgentPredicate]; + XCTAssertFalse(userAgentPopUp.exists); + [userAgent tap]; + if (![userAgentPopUp waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find user agent pop up"); + } +} + +- (void)testCache { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* clearCache = app.buttons[@"Clear cache"]; + if (![clearCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Clear cache"); + } + [clearCache tap]; + + [menu tap]; + + XCUIElement* listCache = app.buttons[@"List cache"]; + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* emptyCachePopup = app.otherElements[@"{\"cacheKeys\":[],\"localStorage\":{}}"]; + if (![emptyCachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find empty cache pop up"); + } + + [menu tap]; + XCUIElement* addCache = app.buttons[@"Add to cache"]; + if (![addCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Add to cache"); + } + [addCache tap]; + [menu tap]; + + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* cachePopup = + app.otherElements[@"{\"cacheKeys\":[\"test_caches_entry\"],\"localStorage\":{\"test_" + @"localStorage\":\"dummy_entry\"}}"]; + if (![cachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find cache pop up"); + } +} + +@end From 13038b36bb85f4137acb0ec23993319de60bbaf0 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 2 Jun 2021 07:29:04 -0700 Subject: [PATCH 002/364] [google_sign_in] Add iOS unit and UI tests (#3996) --- .../google_sign_in/CHANGELOG.md | 4 + .../google_sign_in/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 268 ++++++++++++++++-- .../contents.xcworkspacedata | 5 +- .../xcshareddata/xcschemes/Runner.xcscheme | 20 ++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../ios/RunnerTests/GoogleSignInTests.m | 18 ++ .../example/ios/RunnerTests/Info.plist | 22 ++ .../ios/RunnerUITests/GoogleSignInUITests.m | 47 +++ .../example/ios/RunnerUITests/Info.plist | 22 ++ 10 files changed, 396 insertions(+), 21 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m create mode 100644 packages/google_sign_in/google_sign_in/example/ios/RunnerTests/Info.plist create mode 100644 packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/GoogleSignInUITests.m create mode 100644 packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/Info.plist diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index ecece1bd3cbc..ce0849845e40 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add iOS unit and UI integration test targets. + ## 5.0.4 * Migrate maven repo from jcenter to mavenCentral. diff --git a/packages/google_sign_in/google_sign_in/example/ios/Podfile b/packages/google_sign_in/google_sign_in/example/ios/Podfile index f7d6a5e68c3a..3924e59aa0f9 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Podfile +++ b/packages/google_sign_in/google_sign_in/example/ios/Podfile @@ -29,6 +29,9 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj index 72aaf05b41b2..a3a99abb2d75 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj @@ -16,8 +16,28 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; C2FB9CBA01DB0A2DE5F31E12 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 0263E28FA425D1CE928BDE15 /* libPods-Runner.a */; }; + C56D3B06A42F3B35C1F47A43 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 18AD6475292B9C45B529DDC9 /* libPods-RunnerTests.a */; }; + F76AC1A52666D0540040C8BC /* GoogleSignInTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F76AC1A42666D0540040C8BC /* GoogleSignInTests.m */; }; + F76AC1B32666D0610040C8BC /* GoogleSignInUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F76AC1B22666D0610040C8BC /* GoogleSignInUITests.m */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F76AC1A72666D0540040C8BC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; + F76AC1B52666D0610040C8BC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -33,6 +53,9 @@ /* Begin PBXFileReference section */ 0263E28FA425D1CE928BDE15 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 18AD6475292B9C45B529DDC9 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 37E582FF620A90D0EB2C0851 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 45D93D4513839BFEA2AA74FE /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 5A76713E622F06379AEDEBFA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 5C6F5A6C1EC3B4CB008D64B5 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 5C6F5A6D1EC3B4CB008D64B5 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; @@ -50,6 +73,12 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; F582639B44581540871D9BB0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F76AC1A22666D0540040C8BC /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F76AC1A42666D0540040C8BC /* GoogleSignInTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GoogleSignInTests.m; sourceTree = ""; }; + F76AC1A62666D0540040C8BC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F76AC1B02666D0610040C8BC /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F76AC1B22666D0610040C8BC /* GoogleSignInUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GoogleSignInUITests.m; sourceTree = ""; }; + F76AC1B42666D0610040C8BC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -61,6 +90,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC19F2666D0540040C8BC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C56D3B06A42F3B35C1F47A43 /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F76AC1AD2666D0610040C8BC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -69,6 +113,8 @@ children = ( 5A76713E622F06379AEDEBFA /* Pods-Runner.debug.xcconfig */, F582639B44581540871D9BB0 /* Pods-Runner.release.xcconfig */, + 37E582FF620A90D0EB2C0851 /* Pods-RunnerTests.debug.xcconfig */, + 45D93D4513839BFEA2AA74FE /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -89,6 +135,8 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + F76AC1A32666D0540040C8BC /* RunnerTests */, + F76AC1B12666D0610040C8BC /* RunnerUITests */, 97C146EF1CF9000F007C117D /* Products */, 840012C8B5EDBCF56B0E4AC1 /* Pods */, CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, @@ -99,6 +147,8 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + F76AC1A22666D0540040C8BC /* RunnerTests.xctest */, + F76AC1B02666D0610040C8BC /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -132,10 +182,29 @@ isa = PBXGroup; children = ( 0263E28FA425D1CE928BDE15 /* libPods-Runner.a */, + 18AD6475292B9C45B529DDC9 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; }; + F76AC1A32666D0540040C8BC /* RunnerTests */ = { + isa = PBXGroup; + children = ( + F76AC1A42666D0540040C8BC /* GoogleSignInTests.m */, + F76AC1A62666D0540040C8BC /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; + F76AC1B12666D0610040C8BC /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + F76AC1B22666D0610040C8BC /* GoogleSignInUITests.m */, + F76AC1B42666D0610040C8BC /* Info.plist */, + ); + path = RunnerUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -149,7 +218,6 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, - 95BB15E9E1769C0D146AA592 /* [CP] Embed Pods Frameworks */, 532EA9D341340B1DCD08293D /* [CP] Copy Pods Resources */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); @@ -162,6 +230,43 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F76AC1A12666D0540040C8BC /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F76AC1AB2666D0540040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 27975964E48117AA65B1D6C7 /* [CP] Check Pods Manifest.lock */, + F76AC19E2666D0540040C8BC /* Sources */, + F76AC19F2666D0540040C8BC /* Frameworks */, + F76AC1A02666D0540040C8BC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F76AC1A82666D0540040C8BC /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = F76AC1A22666D0540040C8BC /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F76AC1AF2666D0610040C8BC /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F76AC1B72666D0610040C8BC /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + F76AC1AC2666D0610040C8BC /* Sources */, + F76AC1AD2666D0610040C8BC /* Frameworks */, + F76AC1AE2666D0610040C8BC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F76AC1B62666D0610040C8BC /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = F76AC1B02666D0610040C8BC /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -174,6 +279,16 @@ 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; }; + F76AC1A12666D0540040C8BC = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + F76AC1AF2666D0610040C8BC = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -190,6 +305,8 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + F76AC1A12666D0540040C8BC /* RunnerTests */, + F76AC1AF2666D0610040C8BC /* RunnerUITests */, ); }; /* End PBXProject section */ @@ -207,57 +324,75 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1A02666D0540040C8BC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F76AC1AE2666D0610040C8BC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 27975964E48117AA65B1D6C7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - 532EA9D341340B1DCD08293D /* [CP] Copy Pods Resources */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", - "${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle", ); - name = "[CP] Copy Pods Resources"; + name = "Thin Binary"; outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; }; - 95BB15E9E1769C0D146AA592 /* [CP] Embed Pods Frameworks */ = { + 532EA9D341340B1DCD08293D /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${PODS_ROOT}/../Flutter/Flutter.framework", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", + "${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { @@ -305,8 +440,37 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC19E2666D0540040C8BC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F76AC1A52666D0540040C8BC /* GoogleSignInTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F76AC1AC2666D0610040C8BC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F76AC1B32666D0610040C8BC /* GoogleSignInUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F76AC1A82666D0540040C8BC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F76AC1A72666D0540040C8BC /* PBXContainerItemProxy */; + }; + F76AC1B62666D0610040C8BC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F76AC1B52666D0610040C8BC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -475,6 +639,58 @@ }; name = Release; }; + F76AC1A92666D0540040C8BC /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 37E582FF620A90D0EB2C0851 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + F76AC1AA2666D0540040C8BC /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 45D93D4513839BFEA2AA74FE /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; + F76AC1B82666D0610040C8BC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + F76AC1B92666D0610040C8BC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -496,6 +712,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F76AC1AB2666D0540040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F76AC1A92666D0540040C8BC /* Debug */, + F76AC1AA2666D0540040C8BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F76AC1B72666D0610040C8BC /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F76AC1B82666D0610040C8BC /* Debug */, + F76AC1B92666D0610040C8BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 21a3cc14c74e..919434a6254f 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,9 +2,6 @@ - - + location = "self:"> diff --git a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3bb3697ef41c..f85273f21768 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -37,6 +37,26 @@ + + + + + + + + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m new file mode 100644 index 000000000000..e96a7abe7715 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 google_sign_in; +@import XCTest; + +@interface GoogleSignInTests : XCTestCase +@end + +@implementation GoogleSignInTests + +- (void)testPlugin { + FLTGoogleSignInPlugin* plugin = [[FLTGoogleSignInPlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end diff --git a/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/Info.plist b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/GoogleSignInUITests.m b/packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/GoogleSignInUITests.m new file mode 100644 index 000000000000..52d8da1b5964 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/GoogleSignInUITests.m @@ -0,0 +1,47 @@ +// Copyright 2013 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 os.log; +@import XCTest; + +@interface GoogleSignInUITests : XCTestCase +@property(nonatomic, strong) XCUIApplication* app; +@end + +@implementation GoogleSignInUITests + +- (void)setUp { + self.continueAfterFailure = NO; + + self.app = [[XCUIApplication alloc] init]; + [self.app launch]; +} + +- (void)testSignInPopUp { + XCUIApplication* app = self.app; + + XCUIElement* signInButton = app.buttons[@"SIGN IN"]; + if (![signInButton waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Sign In button"); + } + [signInButton tap]; + + [self allowSignInPermissions]; +} + +- (void)allowSignInPermissions { + // The "Sign In" system permissions pop up isn't caught by + // addUIInterruptionMonitorWithDescription. + XCUIApplication* springboard = + [[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"]; + XCUIElement* permissionAlert = springboard.alerts.firstMatch; + if ([permissionAlert waitForExistenceWithTimeout:5.0]) { + [permissionAlert.buttons[@"Continue"] tap]; + } else { + os_log(OS_LOG_DEFAULT, "Permission alert not detected, continuing."); + } +} + +@end diff --git a/packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/Info.plist b/packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/ios/RunnerUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + From aaea6b6819b08102135aaa5f29381c6c00efb0eb Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 2 Jun 2021 16:30:23 +0200 Subject: [PATCH 003/364] [camera] Prevent crash when setting unsupported FocusMode (#3992) * Check if chosen focus mode is supported * adding unit tests * Added tests for camera focus * Fail 1 test on purpose * formatting * Fix copyright notion * Fix test run * Add documentation and changelog * Update packages/camera/camera/CHANGELOG.md Co-authored-by: Maurits van Beusekom * Improve documentation Co-authored-by: Maurits van Beusekom --- packages/camera/camera/CHANGELOG.md | 4 + packages/camera/camera/example/ios/Podfile | 7 + .../ios/Runner.xcodeproj/project.pbxproj | 208 ++++++++++++++++-- .../xcshareddata/xcschemes/Runner.xcscheme | 10 + .../example/ios/UnitTests/CameraFocusTests.m | 120 ++++++++++ .../camera/example/ios/UnitTests/Info.plist | 22 ++ .../camera/camera/ios/Classes/CameraPlugin.m | 36 ++- packages/camera/camera/pubspec.yaml | 2 +- 8 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 packages/camera/camera/example/ios/UnitTests/CameraFocusTests.m create mode 100644 packages/camera/camera/example/ios/UnitTests/Info.plist diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index e16a14c0ec98..193feecbf920 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1+2 + +* Fix iOS crash when selecting an unsupported FocusMode. + ## 0.8.1+1 * Migrate maven repository from jcenter to mavenCentral. diff --git a/packages/camera/camera/example/ios/Podfile b/packages/camera/camera/example/ios/Podfile index f7d6a5e68c3a..884573b376e8 100644 --- a/packages/camera/camera/example/ios/Podfile +++ b/packages/camera/camera/example/ios/Podfile @@ -29,6 +29,13 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + target 'UnitTests' do + platform :ios, '9.0' + inherit! :search_paths + # Pods for testing + pod 'OCMock', '~> 3.8.1' + end end post_install do |installer| diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index c865799d6a02..d39ed6a65a01 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,16 +7,29 @@ objects = { /* Begin PBXBuildFile section */ + 01010359265BEB94FD7CE839 /* libPods-UnitTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CCBF0769BA2C53F6AED0F17 /* libPods-UnitTests.a */; }; + 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB766A2665316900CE5A93 /* CameraFocusTests.m */; }; + 03BB767326653ABE00CE5A93 /* CameraPluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 75201D617916C49BDEDF852A /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 620DDA07C00B5FF2F937CB5B /* libPods-Runner.a */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D065CD815D405ECB22FB1BBA /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 03BB766D2665316900CE5A93 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -31,14 +44,19 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 03BB76682665316900CE5A93 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 03BB766A2665316900CE5A93 /* CameraFocusTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraFocusTests.m; sourceTree = ""; }; + 03BB766C2665316900CE5A93 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CameraPluginTests.m; path = ../../../ios/Tests/CameraPluginTests.m; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 483D985F075B951ADBAD218E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 620DDA07C00B5FF2F937CB5B /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6CCBF0769BA2C53F6AED0F17 /* libPods-UnitTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-UnitTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 8F7D83D0CFC9B51065F87CE1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -47,25 +65,46 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9AC7510327AD6A32B7CBD9A5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + A903DC9BC9D1CB89BD4FB3CB /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = ""; }; + C2D350ADCDFC81FCB0D6F12C /* Pods-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.release.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 03BB76652665316900CE5A93 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 01010359265BEB94FD7CE839 /* libPods-UnitTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 75201D617916C49BDEDF852A /* libPods-Runner.a in Frameworks */, + D065CD815D405ECB22FB1BBA /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 8A1387E89A6BBC071B75FD6F /* Frameworks */ = { + 03BB76692665316900CE5A93 /* UnitTests */ = { + isa = PBXGroup; + children = ( + 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */, + 03BB766A2665316900CE5A93 /* CameraFocusTests.m */, + 03BB766C2665316900CE5A93 /* Info.plist */, + ); + path = UnitTests; + sourceTree = ""; + }; + 78D1009194BD06C03BED950D /* Frameworks */ = { isa = PBXGroup; children = ( - 620DDA07C00B5FF2F937CB5B /* libPods-Runner.a */, + 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */, + 6CCBF0769BA2C53F6AED0F17 /* libPods-UnitTests.a */, ); name = Frameworks; sourceTree = ""; @@ -86,9 +125,10 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + 03BB76692665316900CE5A93 /* UnitTests */, 97C146EF1CF9000F007C117D /* Products */, - C52D9D4A70956403860EBEB5 /* Pods */, - 8A1387E89A6BBC071B75FD6F /* Frameworks */, + FD386F00E98D73419C929072 /* Pods */, + 78D1009194BD06C03BED950D /* Frameworks */, ); sourceTree = ""; }; @@ -96,6 +136,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 03BB76682665316900CE5A93 /* UnitTests.xctest */, ); name = Products; sourceTree = ""; @@ -124,23 +165,44 @@ name = "Supporting Files"; sourceTree = ""; }; - C52D9D4A70956403860EBEB5 /* Pods */ = { + FD386F00E98D73419C929072 /* Pods */ = { isa = PBXGroup; children = ( - 9AC7510327AD6A32B7CBD9A5 /* Pods-Runner.debug.xcconfig */, - 483D985F075B951ADBAD218E /* Pods-Runner.release.xcconfig */, + 8F7D83D0CFC9B51065F87CE1 /* Pods-Runner.debug.xcconfig */, + A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */, + A903DC9BC9D1CB89BD4FB3CB /* Pods-UnitTests.debug.xcconfig */, + C2D350ADCDFC81FCB0D6F12C /* Pods-UnitTests.release.xcconfig */, ); - name = Pods; + path = Pods; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 03BB76672665316900CE5A93 /* UnitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "UnitTests" */; + buildPhases = ( + 604FC00FF5713F40F2A4441D /* [CP] Check Pods Manifest.lock */, + 03BB76642665316900CE5A93 /* Sources */, + 03BB76652665316900CE5A93 /* Frameworks */, + 03BB76662665316900CE5A93 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 03BB766E2665316900CE5A93 /* PBXTargetDependency */, + ); + name = UnitTests; + productName = camera_exampleTests; + productReference = 03BB76682665316900CE5A93 /* UnitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 3E30118C54AB12C3EB9EDF27 /* [CP] Check Pods Manifest.lock */, + 1D0D227A6719C1144CAE5AB5 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -166,6 +228,11 @@ LastUpgradeCheck = 1100; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { + 03BB76672665316900CE5A93 = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; DevelopmentTeam = 7624MWN53C; @@ -186,11 +253,19 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 03BB76672665316900CE5A93 /* UnitTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 03BB76662665316900CE5A93 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -205,6 +280,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1D0D227A6719C1144CAE5AB5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -219,18 +316,22 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 3E30118C54AB12C3EB9EDF27 /* [CP] Check Pods Manifest.lock */ = { + 604FC00FF5713F40F2A4441D /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( "${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-UnitTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -254,6 +355,15 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 03BB76642665316900CE5A93 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, + 03BB767326653ABE00CE5A93 /* CameraPluginTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -266,6 +376,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 03BB766E2665316900CE5A93 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 03BB766D2665316900CE5A93 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -286,6 +404,55 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 03BB766F2665316900CE5A93 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A903DC9BC9D1CB89BD4FB3CB /* Pods-UnitTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = UnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "io.flutter.plugins.cameraExample.camera-exampleTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 03BB76702665316900CE5A93 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C2D350ADCDFC81FCB0D6F12C /* Pods-UnitTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = UnitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "io.flutter.plugins.cameraExample.camera-exampleTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -439,6 +606,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "UnitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 03BB766F2665316900CE5A93 /* Debug */, + 03BB76702665316900CE5A93 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 44b873626ab3..d9bece2dd771 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -37,6 +37,16 @@ + + + + + +// Mirrors FocusMode in camera.dart +typedef enum { + FocusModeAuto, + FocusModeLocked, +} FocusMode; + +@interface FLTCam : NSObject + +- (void)applyFocusMode; +- (void)applyFocusMode:(FocusMode)focusMode onDevice:(AVCaptureDevice *)captureDevice; +@end + +@interface CameraFocusTests : XCTestCase +@property(readonly, nonatomic) FLTCam *camera; +@property(readonly, nonatomic) id mockDevice; + +@end + +@implementation CameraFocusTests + +- (void)setUp { + _camera = [[FLTCam alloc] init]; + _mockDevice = OCMClassMock([AVCaptureDevice class]); +} + +- (void)tearDown { + // Put teardown code here. This method is called after the invocation of each test method in the + // class. +} + +- (void)testAutoFocusWithContinuousModeSupported_ShouldSetContinuousAutoFocus { + // AVCaptureFocusModeContinuousAutoFocus is supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]).andReturn(true); + // AVCaptureFocusModeContinuousAutoFocus is supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]).andReturn(true); + + // Don't expect setFocusMode:AVCaptureFocusModeAutoFocus + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeAutoFocus]; + + // Run test + [_camera applyFocusMode:FocusModeAuto onDevice:_mockDevice]; + + // Expect setFocusMode:AVCaptureFocusModeContinuousAutoFocus + OCMVerify([_mockDevice setFocusMode:AVCaptureFocusModeContinuousAutoFocus]); +} + +- (void)testAutoFocusWithContinuousModeNotSupported_ShouldSetAutoFocus { + // AVCaptureFocusModeContinuousAutoFocus is not supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]) + .andReturn(false); + // AVCaptureFocusModeContinuousAutoFocus is supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]).andReturn(true); + + // Don't expect setFocusMode:AVCaptureFocusModeContinuousAutoFocus + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; + + // Run test + [_camera applyFocusMode:FocusModeAuto onDevice:_mockDevice]; + + // Expect setFocusMode:AVCaptureFocusModeAutoFocus + OCMVerify([_mockDevice setFocusMode:AVCaptureFocusModeAutoFocus]); +} + +- (void)testAutoFocusWithNoModeSupported_ShouldSetNothing { + // AVCaptureFocusModeContinuousAutoFocus is not supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]) + .andReturn(false); + // AVCaptureFocusModeContinuousAutoFocus is not supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]).andReturn(false); + + // Don't expect any setFocus + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeAutoFocus]; + + // Run test + [_camera applyFocusMode:FocusModeAuto onDevice:_mockDevice]; +} + +- (void)testLockedFocusWithModeSupported_ShouldSetModeAutoFocus { + // AVCaptureFocusModeContinuousAutoFocus is supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]).andReturn(true); + // AVCaptureFocusModeContinuousAutoFocus is supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]).andReturn(true); + + // Don't expect any setFocus + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; + + // Run test + [_camera applyFocusMode:FocusModeLocked onDevice:_mockDevice]; + + // Expect setFocusMode:AVCaptureFocusModeAutoFocus + OCMVerify([_mockDevice setFocusMode:AVCaptureFocusModeAutoFocus]); +} + +- (void)testLockedFocusWithModeNotSupported_ShouldSetNothing { + // AVCaptureFocusModeContinuousAutoFocus is supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]).andReturn(true); + // AVCaptureFocusModeContinuousAutoFocus is not supported + OCMStub([_mockDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]).andReturn(false); + + // Don't expect any setFocus + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; + [[_mockDevice reject] setFocusMode:AVCaptureFocusModeAutoFocus]; + + // Run test + [_camera applyFocusMode:FocusModeLocked onDevice:_mockDevice]; +} + +@end diff --git a/packages/camera/camera/example/ios/UnitTests/Info.plist b/packages/camera/camera/example/ios/UnitTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/camera/camera/example/ios/UnitTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index ac844059f599..1e818abda0ac 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -994,20 +994,40 @@ - (void)setFocusModeWithResult:(FlutterResult)result mode:(NSString *)modeStr { } - (void)applyFocusMode { - [_captureDevice lockForConfiguration:nil]; - switch (_focusMode) { + [self applyFocusMode:_focusMode onDevice:_captureDevice]; +} + +/** + * Applies FocusMode on the AVCaptureDevice. + * + * If the @c focusMode is set to FocusModeAuto the AVCaptureDevice is configured to use + * AVCaptureFocusModeContinuousModeAutoFocus when supported, otherwise it is set to + * AVCaptureFocusModeAutoFocus. If neither AVCaptureFocusModeContinuousModeAutoFocus nor + * AVCaptureFocusModeAutoFocus are supported focus mode will not be set. + * If @c focusMode is set to FocusModeLocked the AVCaptureDevice is configured to use + * AVCaptureFocusModeAutoFocus. If AVCaptureFocusModeAutoFocus is not supported focus mode will not + * be set. + * + * @param focusMode The focus mode that should be applied to the @captureDevice instance. + * @param captureDevice The AVCaptureDevice to which the @focusMode will be applied. + */ +- (void)applyFocusMode:(FocusMode)focusMode onDevice:(AVCaptureDevice *)captureDevice { + [captureDevice lockForConfiguration:nil]; + switch (focusMode) { case FocusModeLocked: - [_captureDevice setFocusMode:AVCaptureFocusModeAutoFocus]; + if ([captureDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]) { + [captureDevice setFocusMode:AVCaptureFocusModeAutoFocus]; + } break; case FocusModeAuto: - if ([_captureDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]) { - [_captureDevice setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; - } else { - [_captureDevice setFocusMode:AVCaptureFocusModeAutoFocus]; + if ([captureDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]) { + [captureDevice setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; + } else if ([captureDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]) { + [captureDevice setFocusMode:AVCaptureFocusModeAutoFocus]; } break; } - [_captureDevice unlockForConfiguration]; + [captureDevice unlockForConfiguration]; } - (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y { diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index dc7bf8ee82bc..de286835a0ed 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.8.1+1 +version: 0.8.1+2 environment: sdk: ">=2.12.0 <3.0.0" From e686de5c010c3e08c2061d6091dee396b7dd45f5 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Wed, 2 Jun 2021 16:31:57 +0200 Subject: [PATCH 004/364] [image_picker] Removed redundant request for camera permission (#4001) * Removed all permissions and updated unit tests * Updated pubspec version and changelog. Updated pubspec version and changelog. * Update version --- .../image_picker/image_picker/CHANGELOG.md | 4 + .../imagepicker/ImagePickerDelegate.java | 103 +-------------- .../imagepicker/ImagePickerPlugin.java | 3 - .../imagepicker/ImagePickerDelegateTest.java | 123 ++++-------------- .../image_picker/image_picker/pubspec.yaml | 2 +- 5 files changed, 34 insertions(+), 201 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 515845e07fc3..703b00bf92a7 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.0+1 + +* Removed redundant request for camera permissions. + ## 0.8.0 * BREAKING CHANGE: Changed storage location for captured images and videos to internal cache on Android, diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index c934b54a1f8e..41df851d1d00 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -4,7 +4,6 @@ package io.flutter.plugins.imagepicker; -import android.Manifest; import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; @@ -15,7 +14,6 @@ import android.os.Build; import android.provider.MediaStore; import androidx.annotation.VisibleForTesting; -import androidx.core.app.ActivityCompat; import androidx.core.content.FileProvider; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -42,19 +40,7 @@ enum CameraDevice { * means that the chooseImageFromGallery() or takeImageWithCamera() method was called at least * twice. In this case, stop executing and finish with an error. * - *

2. Check that a required runtime permission has been granted. The takeImageWithCamera() method - * checks that {@link Manifest.permission#CAMERA} has been granted. - * - *

The permission check can end up in two different outcomes: - * - *

A) If the permission has already been granted, continue with picking the image from gallery or - * camera. - * - *

B) If the permission hasn't already been granted, ask for the permission from the user. If the - * user grants the permission, proceed with step #3. If the user denies the permission, stop doing - * anything else and finish with a null result. - * - *

3. Launch the gallery or camera for picking the image, depending on whether + *

2. Launch the gallery or camera for picking the image, depending on whether * chooseImageFromGallery() or takeImageWithCamera() was called. * *

This can end up in three different outcomes: @@ -69,15 +55,11 @@ enum CameraDevice { * *

C) User cancels picking an image. Finish with null result. */ -public class ImagePickerDelegate - implements PluginRegistry.ActivityResultListener, - PluginRegistry.RequestPermissionsResultListener { +public class ImagePickerDelegate implements PluginRegistry.ActivityResultListener { @VisibleForTesting static final int REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY = 2342; @VisibleForTesting static final int REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA = 2343; - @VisibleForTesting static final int REQUEST_CAMERA_IMAGE_PERMISSION = 2345; @VisibleForTesting static final int REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY = 2352; @VisibleForTesting static final int REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA = 2353; - @VisibleForTesting static final int REQUEST_CAMERA_VIDEO_PERMISSION = 2355; @VisibleForTesting final String fileProviderName; @@ -85,20 +67,11 @@ public class ImagePickerDelegate @VisibleForTesting final File externalFilesDirectory; private final ImageResizer imageResizer; private final ImagePickerCache cache; - private final PermissionManager permissionManager; private final IntentResolver intentResolver; private final FileUriResolver fileUriResolver; private final FileUtils fileUtils; private CameraDevice cameraDevice; - interface PermissionManager { - boolean isPermissionGranted(String permissionName); - - void askForPermission(String permissionName, int requestCode); - - boolean needRequestCameraPermission(); - } - interface IntentResolver { boolean resolveActivity(Intent intent); } @@ -129,23 +102,6 @@ public ImagePickerDelegate( null, null, cache, - new PermissionManager() { - @Override - public boolean isPermissionGranted(String permissionName) { - return ActivityCompat.checkSelfPermission(activity, permissionName) - == PackageManager.PERMISSION_GRANTED; - } - - @Override - public void askForPermission(String permissionName, int requestCode) { - ActivityCompat.requestPermissions(activity, new String[] {permissionName}, requestCode); - } - - @Override - public boolean needRequestCameraPermission() { - return ImagePickerUtils.needRequestCameraPermission(activity); - } - }, new IntentResolver() { @Override public boolean resolveActivity(Intent intent) { @@ -187,7 +143,6 @@ public void onScanCompleted(String path, Uri uri) { final MethodChannel.Result result, final MethodCall methodCall, final ImagePickerCache cache, - final PermissionManager permissionManager, final IntentResolver intentResolver, final FileUriResolver fileUriResolver, final FileUtils fileUtils) { @@ -197,7 +152,6 @@ public void onScanCompleted(String path, Uri uri) { this.fileProviderName = activity.getPackageName() + ".flutter.image_provider"; this.pendingResult = result; this.methodCall = methodCall; - this.permissionManager = permissionManager; this.intentResolver = intentResolver; this.fileUriResolver = fileUriResolver; this.fileUtils = fileUtils; @@ -269,13 +223,6 @@ public void takeVideoWithCamera(MethodCall methodCall, MethodChannel.Result resu return; } - if (needRequestCameraPermission() - && !permissionManager.isPermissionGranted(Manifest.permission.CAMERA)) { - permissionManager.askForPermission( - Manifest.permission.CAMERA, REQUEST_CAMERA_VIDEO_PERMISSION); - return; - } - launchTakeVideoWithCameraIntent(); } @@ -328,22 +275,9 @@ public void takeImageWithCamera(MethodCall methodCall, MethodChannel.Result resu return; } - if (needRequestCameraPermission() - && !permissionManager.isPermissionGranted(Manifest.permission.CAMERA)) { - permissionManager.askForPermission( - Manifest.permission.CAMERA, REQUEST_CAMERA_IMAGE_PERMISSION); - return; - } launchTakeImageWithCameraIntent(); } - private boolean needRequestCameraPermission() { - if (permissionManager == null) { - return false; - } - return permissionManager.needRequestCameraPermission(); - } - private void launchTakeImageWithCameraIntent() { Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (cameraDevice == CameraDevice.FRONT) { @@ -401,39 +335,6 @@ private void grantUriPermissions(Intent intent, Uri imageUri) { } } - @Override - public boolean onRequestPermissionsResult( - int requestCode, String[] permissions, int[] grantResults) { - boolean permissionGranted = - grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED; - - switch (requestCode) { - case REQUEST_CAMERA_IMAGE_PERMISSION: - if (permissionGranted) { - launchTakeImageWithCameraIntent(); - } - break; - case REQUEST_CAMERA_VIDEO_PERMISSION: - if (permissionGranted) { - launchTakeVideoWithCameraIntent(); - } - break; - default: - return false; - } - - if (!permissionGranted) { - switch (requestCode) { - case REQUEST_CAMERA_IMAGE_PERMISSION: - case REQUEST_CAMERA_VIDEO_PERMISSION: - finishWithError("camera_access_denied", "The user did not allow camera access."); - break; - } - } - - return true; - } - @Override public boolean onActivityResult(int requestCode, int resultCode, Intent data) { switch (requestCode) { diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java index bffc903b531e..b4e7e8a06ce3 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java @@ -192,11 +192,9 @@ private void setup( // V1 embedding setup for activity listeners. application.registerActivityLifecycleCallbacks(observer); registrar.addActivityResultListener(delegate); - registrar.addRequestPermissionsResultListener(delegate); } else { // V2 embedding setup for activity listeners. activityBinding.addActivityResultListener(delegate); - activityBinding.addRequestPermissionsResultListener(delegate); lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(activityBinding); lifecycle.addObserver(observer); } @@ -204,7 +202,6 @@ private void setup( private void tearDown() { activityBinding.removeActivityResultListener(delegate); - activityBinding.removeRequestPermissionsResultListener(delegate); activityBinding = null; lifecycle.removeObserver(observer); lifecycle = null; diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index da53b10b50f5..5b66814de761 100644 --- a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -14,7 +14,6 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import android.Manifest; import android.app.Activity; import android.content.Context; import android.content.Intent; @@ -40,7 +39,6 @@ public class ImagePickerDelegateTest { @Mock ImageResizer mockImageResizer; @Mock MethodCall mockMethodCall; @Mock MethodChannel.Result mockResult; - @Mock ImagePickerDelegate.PermissionManager mockPermissionManager; @Mock ImagePickerDelegate.IntentResolver mockIntentResolver; @Mock FileUtils mockFileUtils; @Mock Intent mockIntent; @@ -104,11 +102,7 @@ public void chooseImageFromGallery_WhenPendingResultExists_FinishesWithAlreadyAc } @Test - public void - chooseImageFromGallery_WhenHasExternalStoragePermission_LaunchesChooseFromGalleryIntent() { - when(mockPermissionManager.isPermissionGranted(Manifest.permission.READ_EXTERNAL_STORAGE)) - .thenReturn(true); - + public void chooseImageFromGallery_LaunchesChooseFromGalleryIntent() { ImagePickerDelegate delegate = createDelegate(); delegate.chooseImageFromGallery(mockMethodCall, mockResult); @@ -127,50 +121,31 @@ public void takeImageWithCamera_WhenPendingResultExists_FinishesWithAlreadyActiv verifyNoMoreInteractions(mockResult); } - @Test - public void takeImageWithCamera_WhenHasNoCameraPermission_RequestsForPermission() { - when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(false); - when(mockPermissionManager.needRequestCameraPermission()).thenReturn(true); - - ImagePickerDelegate delegate = createDelegate(); - delegate.takeImageWithCamera(mockMethodCall, mockResult); - - verify(mockPermissionManager) - .askForPermission( - Manifest.permission.CAMERA, ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION); - } - - @Test - public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermission() { - when(mockPermissionManager.needRequestCameraPermission()).thenReturn(false); - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); - - ImagePickerDelegate delegate = createDelegate(); - delegate.takeImageWithCamera(mockMethodCall, mockResult); - - verify(mockActivity) - .startActivityForResult( - any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); - } - @Test public void - takeImageWithCamera_WhenHasCameraPermission_AndAnActivityCanHandleCameraIntent_LaunchesTakeWithCameraIntent() { - when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); + takeImageWithCamera_WhenAnActivityCanHandleCameraIntent_LaunchesTakeWithCameraIntent() { when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); - ImagePickerDelegate delegate = createDelegate(); - delegate.takeImageWithCamera(mockMethodCall, mockResult); + MockedStatic mockStaticFile = Mockito.mockStatic(File.class); + mockStaticFile + .when(() -> File.createTempFile(any(), any(), any())) + .thenReturn(new File("/tmpfile")); - verify(mockActivity) - .startActivityForResult( - any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + try { + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + } finally { + mockStaticFile.close(); + } } @Test public void - takeImageWithCamera_WhenHasCameraPermission_AndNoActivityToHandleCameraIntent_FinishesWithNoCamerasAvailableError() { - when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); + takeImageWithCamera_WhenNoActivityToHandleCameraIntent_FinishesWithNoCamerasAvailableError() { when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(false); ImagePickerDelegate delegate = createDelegate(); @@ -183,7 +158,6 @@ public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermis @Test public void takeImageWithCamera_WritesImageToCacheDirectory() { - when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); MockedStatic mockStaticFile = Mockito.mockStatic(File.class); @@ -191,57 +165,16 @@ public void takeImageWithCamera_WritesImageToCacheDirectory() { .when(() -> File.createTempFile(any(), any(), any())) .thenReturn(new File("/tmpfile")); - ImagePickerDelegate delegate = createDelegate(); - delegate.takeImageWithCamera(mockMethodCall, mockResult); + try { + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); - mockStaticFile.verify( - () -> File.createTempFile(any(), eq(".jpg"), eq(new File("/image_picker_cache"))), - times(1)); - } - - @Test - public void onRequestPermissionsResult_WhenCameraPermissionDenied_FinishesWithError() { - ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); - - delegate.onRequestPermissionsResult( - ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION, - new String[] {Manifest.permission.CAMERA}, - new int[] {PackageManager.PERMISSION_DENIED}); - - verify(mockResult).error("camera_access_denied", "The user did not allow camera access.", null); - verifyNoMoreInteractions(mockResult); - } - - @Test - public void - onRequestTakeVideoPermissionsResult_WhenCameraPermissionGranted_LaunchesTakeVideoWithCameraIntent() { - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); - - ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); - delegate.onRequestPermissionsResult( - ImagePickerDelegate.REQUEST_CAMERA_VIDEO_PERMISSION, - new String[] {Manifest.permission.CAMERA}, - new int[] {PackageManager.PERMISSION_GRANTED}); - - verify(mockActivity) - .startActivityForResult( - any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA)); - } - - @Test - public void - onRequestTakeImagePermissionsResult_WhenCameraPermissionGranted_LaunchesTakeWithCameraIntent() { - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); - - ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); - delegate.onRequestPermissionsResult( - ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION, - new String[] {Manifest.permission.CAMERA}, - new int[] {PackageManager.PERMISSION_GRANTED}); - - verify(mockActivity) - .startActivityForResult( - any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + mockStaticFile.verify( + () -> File.createTempFile(any(), eq(".jpg"), eq(new File("/image_picker_cache"))), + times(1)); + } finally { + mockStaticFile.close(); + } } @Test @@ -362,7 +295,6 @@ private ImagePickerDelegate createDelegate() { null, null, cache, - mockPermissionManager, mockIntentResolver, mockFileUriResolver, mockFileUtils); @@ -371,12 +303,11 @@ private ImagePickerDelegate createDelegate() { private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { return new ImagePickerDelegate( mockActivity, - null, + new File("/image_picker_cache"), mockImageResizer, mockResult, mockMethodCall, cache, - mockPermissionManager, mockIntentResolver, mockFileUriResolver, mockFileUtils); diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 95ea8086d52c..c24fdd01fb1c 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.0 +version: 0.8.0+1 environment: sdk: ">=2.12.0 <3.0.0" From 3623f62c1d8e5679027b609e8a9cd39a46d96c77 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 2 Jun 2021 17:40:17 +0200 Subject: [PATCH 005/364] Some small documentation fixes (#3999) * Two small documentation fixes * Fix url to plugin_tool format --- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- CONTRIBUTING.md | 2 +- script/tool/README.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec9a6dd6ab92..d470ac18bf5c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. -- [ ] I read and followed the [Flutter Style Guide] and the [C++, Objective-C, Java style guides]. (Note that unlike the flutter/flutter repo, the flutter/plugins repo does use `dart format`. See [plugin_tool format](../script/tool/README.md#format-code)) +- [ ] I read and followed the [Flutter Style Guide] and the [C++, Objective-C, Java style guides]. (Note that unlike the flutter/flutter repo, the flutter/plugins repo does use `dart format`. See [plugin_tool format]) - [ ] I signed the [CLA]. - [ ] The title of the PR starts with the name of the plugin surrounded by square brackets, e.g. `[shared_preferences]` - [ ] I listed at least one issue that this PR fixes in the description above. @@ -30,3 +30,4 @@ If you need help, consider asking for advice on the #hackers-new channel on [Dis [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat [pub versioning philosophy]: https://dart.dev/tools/pub/versioning +[plugin_tool format]: https://github.com/flutter/plugins/blob/master/script/tool/README.md#format-code diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83782718a023..1c4115fb640f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ _See also: [Flutter's code of conduct](https://github.com/flutter/flutter/blob/m ## Welcome For an introduction to contributing to Flutter, see [our contributor -guide][https://github.com/flutter/flutter/blob/master/CONTRIBUTING.md]. +guide](https://github.com/flutter/flutter/blob/master/CONTRIBUTING.md). Additional resources specific to the plugins repository: - [Setting up the Plugins development diff --git a/script/tool/README.md b/script/tool/README.md index c142b66178b4..3e9484c3ff0c 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -58,7 +58,7 @@ Note that the `plugins` argument, despite the name, applies to any package. ```sh cd -dart run /script/tool/lib/src/main.dart format --plugins plugin_name +dart run ./script/tool/lib/src/main.dart format --plugins plugin_name ``` ### Run the Dart Static Analyzer From 78f2ace929a680a157597d842494fe5df7292c2f Mon Sep 17 00:00:00 2001 From: creativecreatorormaybenot Date: Wed, 2 Jun 2021 16:09:03 +0000 Subject: [PATCH 006/364] [url_launcher] Fix breaking change version conflict (#4000) --- packages/url_launcher/url_launcher/CHANGELOG.md | 7 +++++++ packages/url_launcher/url_launcher/pubspec.yaml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 3ff1a0828d8b..03a3f6fb0b76 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,10 @@ +## 6.0.6 + +* Require `url_launcher_platform_interface` 2.0.3. This fixes an issue + where 6.0.5 could fail to compile in some projects due to internal + changes in that version that were not compatible with earlier versions + of `url_launcher_platform_interface`. + ## 6.0.5 * Add iOS unit and UI integration test targets. diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index ca5dd6be5302..00cfc218ce9e 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.5 +version: 6.0.6 environment: sdk: ">=2.12.0 <3.0.0" @@ -37,7 +37,7 @@ dependencies: # https://github.com/flutter/flutter/issues/46264 url_launcher_linux: ^2.0.0 url_launcher_macos: ^2.0.0 - url_launcher_platform_interface: ^2.0.0 + url_launcher_platform_interface: ^2.0.3 url_launcher_web: ^2.0.0 url_launcher_windows: ^2.0.0 From 4ec1169d07402411814617cae53714a8853f7d61 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 2 Jun 2021 11:54:05 -0700 Subject: [PATCH 007/364] [path_provider] Add iOS unit tests (#3998) --- .../path_provider/path_provider/CHANGELOG.md | 4 + .../path_provider/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 161 +++++++++++++++--- .../contents.xcworkspacedata | 5 +- .../xcshareddata/xcschemes/Runner.xcscheme | 10 ++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/ios/RunnerTests/Info.plist | 22 +++ .../ios/RunnerTests/PathProviderTests.m | 18 ++ 8 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 packages/path_provider/path_provider/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/path_provider/path_provider/example/ios/RunnerTests/Info.plist create mode 100644 packages/path_provider/path_provider/example/ios/RunnerTests/PathProviderTests.m diff --git a/packages/path_provider/path_provider/CHANGELOG.md b/packages/path_provider/path_provider/CHANGELOG.md index 7486affef3ba..ca05c24eedb7 100644 --- a/packages/path_provider/path_provider/CHANGELOG.md +++ b/packages/path_provider/path_provider/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add iOS unit test target. + ## 2.0.2 * Migrate maven repository from jcenter to mavenCentral. diff --git a/packages/path_provider/path_provider/example/ios/Podfile b/packages/path_provider/path_provider/example/ios/Podfile index f7d6a5e68c3a..3924e59aa0f9 100644 --- a/packages/path_provider/path_provider/example/ios/Podfile +++ b/packages/path_provider/path_provider/example/ios/Podfile @@ -29,6 +29,9 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj index 475c9d8f64a4..31a9592b5dd8 100644 --- a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,18 +9,26 @@ /* Begin PBXBuildFile section */ 2D9222481EC32A19007564B0 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 2D9222471EC32A19007564B0 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 60774162343BF6F19B3D65CE /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E2EF24BBF807F7F7B95F2B9 /* libPods-RunnerTests.a */; }; 85DDFCF6BBDEE02B9D9F8138 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0EE60090AA5F3AAAF2175B6 /* libPods-Runner.a */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F76AC1DF26671E960040C8BC /* PathProviderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F76AC1DE26671E960040C8BC /* PathProviderTests.m */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F76AC1E126671E960040C8BC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -28,8 +36,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -37,17 +43,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0E2EF24BBF807F7F7B95F2B9 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 29F2567B3AE74A9113ED3394 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 2D9222461EC32A19007564B0 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 2D9222471EC32A19007564B0 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 694A199F61914F41AAFD0B7F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 6EB685EA3DDA2EED39600D11 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -56,6 +63,9 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C0EE60090AA5F3AAAF2175B6 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; D317CA1E83064E01753D8BB5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F76AC1DC26671E960040C8BC /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F76AC1DE26671E960040C8BC /* PathProviderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PathProviderTests.m; sourceTree = ""; }; + F76AC1E026671E960040C8BC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,12 +73,18 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, 85DDFCF6BBDEE02B9D9F8138 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1D926671E960040C8BC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 60774162343BF6F19B3D65CE /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -77,6 +93,8 @@ children = ( 694A199F61914F41AAFD0B7F /* Pods-Runner.debug.xcconfig */, D317CA1E83064E01753D8BB5 /* Pods-Runner.release.xcconfig */, + 6EB685EA3DDA2EED39600D11 /* Pods-RunnerTests.debug.xcconfig */, + 29F2567B3AE74A9113ED3394 /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -84,9 +102,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -99,6 +115,7 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + F76AC1DD26671E960040C8BC /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, 840012C8B5EDBCF56B0E4AC1 /* Pods */, CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, @@ -109,6 +126,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + F76AC1DC26671E960040C8BC /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -141,10 +159,20 @@ isa = PBXGroup; children = ( C0EE60090AA5F3AAAF2175B6 /* libPods-Runner.a */, + 0E2EF24BBF807F7F7B95F2B9 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; }; + F76AC1DD26671E960040C8BC /* RunnerTests */ = { + isa = PBXGroup; + children = ( + F76AC1DE26671E960040C8BC /* PathProviderTests.m */, + F76AC1E026671E960040C8BC /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -158,7 +186,6 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, - 95BB15E9E1769C0D146AA592 /* [CP] Embed Pods Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); buildRules = ( @@ -170,6 +197,25 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F76AC1DB26671E960040C8BC /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F76AC1E526671E960040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 31566AD39C1C7EF9EB261E6F /* [CP] Check Pods Manifest.lock */, + F76AC1D826671E960040C8BC /* Sources */, + F76AC1D926671E960040C8BC /* Frameworks */, + F76AC1DA26671E960040C8BC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F76AC1E226671E960040C8BC /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = F76AC1DC26671E960040C8BC /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -182,6 +228,11 @@ 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; }; + F76AC1DB26671E960040C8BC = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -198,6 +249,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + F76AC1DB26671E960040C8BC /* RunnerTests */, ); }; /* End PBXProject section */ @@ -214,37 +266,51 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1DA26671E960040C8BC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 31566AD39C1C7EF9EB261E6F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - 95BB15E9E1769C0D146AA592 /* [CP] Embed Pods Frameworks */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; + name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -291,8 +357,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1D826671E960040C8BC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F76AC1DF26671E960040C8BC /* PathProviderTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F76AC1E226671E960040C8BC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F76AC1E126671E960040C8BC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -315,7 +397,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +453,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -463,6 +543,34 @@ }; name = Release; }; + F76AC1E326671E960040C8BC /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6EB685EA3DDA2EED39600D11 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + F76AC1E426671E960040C8BC /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 29F2567B3AE74A9113ED3394 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -484,6 +592,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F76AC1E526671E960040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F76AC1E326671E960040C8BC /* Debug */, + F76AC1E426671E960040C8BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 21a3cc14c74e..919434a6254f 100644 --- a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,9 +2,6 @@ - - + location = "self:"> diff --git a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3bb3697ef41c..8501fd2bb642 100644 --- a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -37,6 +37,16 @@ + + + + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/path_provider/path_provider/example/ios/RunnerTests/Info.plist b/packages/path_provider/path_provider/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/path_provider/path_provider/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/path_provider/path_provider/example/ios/RunnerTests/PathProviderTests.m b/packages/path_provider/path_provider/example/ios/RunnerTests/PathProviderTests.m new file mode 100644 index 000000000000..be48ea6b7ddf --- /dev/null +++ b/packages/path_provider/path_provider/example/ios/RunnerTests/PathProviderTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 path_provider; +@import XCTest; + +@interface PathProviderTests : XCTestCase +@end + +@implementation PathProviderTests + +- (void)testPlugin { + FLTPathProviderPlugin* plugin = [[FLTPathProviderPlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end From 2d70191bc2d69744fe7c8b22bb44846242d24df8 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 2 Jun 2021 12:56:29 -0700 Subject: [PATCH 008/364] [ios_platform_images] Add iOS unit tests (#3997) --- packages/ios_platform_images/CHANGELOG.md | 4 + .../ios_platform_images/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 172 +++++++++++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 18 +- .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/ios/RunnerTests/Info.plist | 22 +++ .../ios/RunnerTests/IosPlatformImagesTests.m | 18 ++ .../ios_platform_images/example/lib/main.dart | 7 +- 8 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 packages/ios_platform_images/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/ios_platform_images/example/ios/RunnerTests/Info.plist create mode 100644 packages/ios_platform_images/example/ios/RunnerTests/IosPlatformImagesTests.m diff --git a/packages/ios_platform_images/CHANGELOG.md b/packages/ios_platform_images/CHANGELOG.md index bb87b7d6ff81..60db21a450d8 100644 --- a/packages/ios_platform_images/CHANGELOG.md +++ b/packages/ios_platform_images/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add iOS unit test target. + ## 0.2.0 * Migrate to null safety. diff --git a/packages/ios_platform_images/example/ios/Podfile b/packages/ios_platform_images/example/ios/Podfile index 1e8c3c90a55e..397864535f5d 100644 --- a/packages/ios_platform_images/example/ios/Podfile +++ b/packages/ios_platform_images/example/ios/Podfile @@ -32,6 +32,9 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj index 5cf073311353..d09b09f7cea9 100644 --- a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj @@ -15,8 +15,20 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A30D9778BC0D4D09580CF4BE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 906079E3CC5A6FAB808EAF1E /* Pods_Runner.framework */; }; + F76AC1C1266713D00040C8BC /* IosPlatformImagesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F76AC1C0266713D00040C8BC /* IosPlatformImagesTests.m */; }; + FC73B055B2CD2E32A3E50B27 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AD2C5EF0E06B6EC7EBCB922C /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F76AC1C3266713D00040C8BC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -31,6 +43,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0B20D3254D8E1A2B01D83810 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 0DE21BF62447752100097E3A /* textfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = textfile; sourceTree = ""; }; 0EF1CD9A3A3064B5289EF22E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; @@ -40,6 +53,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 80830F517E3E8B75B2D3AC0A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 906079E3CC5A6FAB808EAF1E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -48,7 +62,12 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AD2C5EF0E06B6EC7EBCB922C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D1A761179BC59B1BAEE63036 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D36FEDC657E1CE88220062D7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F76AC1BE266713D00040C8BC /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F76AC1C0266713D00040C8BC /* IosPlatformImagesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IosPlatformImagesTests.m; sourceTree = ""; }; + F76AC1C2266713D00040C8BC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -60,6 +79,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1BB266713D00040C8BC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FC73B055B2CD2E32A3E50B27 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -69,6 +96,9 @@ D1A761179BC59B1BAEE63036 /* Pods-Runner.debug.xcconfig */, 0EF1CD9A3A3064B5289EF22E /* Pods-Runner.release.xcconfig */, 4B56C310C5932F84CD6C17AC /* Pods-Runner.profile.xcconfig */, + 0B20D3254D8E1A2B01D83810 /* Pods-RunnerTests.debug.xcconfig */, + D36FEDC657E1CE88220062D7 /* Pods-RunnerTests.release.xcconfig */, + 80830F517E3E8B75B2D3AC0A /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -89,6 +119,7 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + F76AC1BF266713D00040C8BC /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, 790F6E36EBDB3EC4A899BEF5 /* Pods */, DBEBA2309FD49D5C34798105 /* Frameworks */, @@ -99,6 +130,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + F76AC1BE266713D00040C8BC /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -111,7 +143,6 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, - 97C146F11CF9000F007C117D /* Supporting Files */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -120,19 +151,22 @@ path = Runner; sourceTree = ""; }; - 97C146F11CF9000F007C117D /* Supporting Files */ = { + DBEBA2309FD49D5C34798105 /* Frameworks */ = { isa = PBXGroup; children = ( + 906079E3CC5A6FAB808EAF1E /* Pods_Runner.framework */, + AD2C5EF0E06B6EC7EBCB922C /* Pods_RunnerTests.framework */, ); - name = "Supporting Files"; + name = Frameworks; sourceTree = ""; }; - DBEBA2309FD49D5C34798105 /* Frameworks */ = { + F76AC1BF266713D00040C8BC /* RunnerTests */ = { isa = PBXGroup; children = ( - 906079E3CC5A6FAB808EAF1E /* Pods_Runner.framework */, + F76AC1C0266713D00040C8BC /* IosPlatformImagesTests.m */, + F76AC1C2266713D00040C8BC /* Info.plist */, ); - name = Frameworks; + path = RunnerTests; sourceTree = ""; }; /* End PBXGroup section */ @@ -160,6 +194,25 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F76AC1BD266713D00040C8BC /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F76AC1C8266713D00040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C102F13F37851E08F0608EE5 /* [CP] Check Pods Manifest.lock */, + F76AC1BA266713D00040C8BC /* Sources */, + F76AC1BB266713D00040C8BC /* Frameworks */, + F76AC1BC266713D00040C8BC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F76AC1C4266713D00040C8BC /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = F76AC1BE266713D00040C8BC /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -173,6 +226,12 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + F76AC1BD266713D00040C8BC = { + CreatedOnToolsVersion = 12.5; + DevelopmentTeam = S8QB4VV633; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -189,6 +248,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + F76AC1BD266713D00040C8BC /* RunnerTests */, ); }; /* End PBXProject section */ @@ -206,6 +266,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1BC266713D00040C8BC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -277,6 +344,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + C102F13F37851E08F0608EE5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -289,8 +378,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC1BA266713D00040C8BC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F76AC1C1266713D00040C8BC /* IosPlatformImagesTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F76AC1C4266713D00040C8BC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F76AC1C3266713D00040C8BC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -546,6 +651,51 @@ }; name = Release; }; + F76AC1C5266713D00040C8BC /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B20D3254D8E1A2B01D83810 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = S8QB4VV633; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + F76AC1C6266713D00040C8BC /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D36FEDC657E1CE88220062D7 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = S8QB4VV633; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; + F76AC1C7266713D00040C8BC /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 80830F517E3E8B75B2D3AC0A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = S8QB4VV633; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -569,6 +719,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F76AC1C8266713D00040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F76AC1C5266713D00040C8BC /* Debug */, + F76AC1C6266713D00040C8BC /* Release */, + F76AC1C7266713D00040C8BC /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140cfdb3f..6de5fabfee04 100644 --- a/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,8 +27,6 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + + + + + + - - + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/ios_platform_images/example/ios/RunnerTests/Info.plist b/packages/ios_platform_images/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/ios_platform_images/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/ios_platform_images/example/ios/RunnerTests/IosPlatformImagesTests.m b/packages/ios_platform_images/example/ios/RunnerTests/IosPlatformImagesTests.m new file mode 100644 index 000000000000..747719d30276 --- /dev/null +++ b/packages/ios_platform_images/example/ios/RunnerTests/IosPlatformImagesTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 ios_platform_images; +@import XCTest; + +@interface IosPlatformImagesTests : XCTestCase +@end + +@implementation IosPlatformImagesTests + +- (void)testPlugin { + IosPlatformImagesPlugin* plugin = [[IosPlatformImagesPlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end diff --git a/packages/ios_platform_images/example/lib/main.dart b/packages/ios_platform_images/example/lib/main.dart index d1b07b0a5fee..1546edca8c90 100644 --- a/packages/ios_platform_images/example/lib/main.dart +++ b/packages/ios_platform_images/example/lib/main.dart @@ -29,8 +29,11 @@ class _MyAppState extends State { title: const Text('Plugin example app'), ), body: Center( - // "pug" is a resource in Assets.xcassets. - child: Image(image: IosPlatformImages.load("flutter")), + // "flutter" is a resource in Assets.xcassets. + child: Image( + image: IosPlatformImages.load("flutter"), + semanticLabel: 'Flutter logo', + ), ), ), ); From bd869328504ae7e5634defc97d52ffb99c3e40fb Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 2 Jun 2021 22:28:49 +0200 Subject: [PATCH 009/364] Add Restore purchases button to example (#3950) --- .../in_app_purchase_ios/CHANGELOG.md | 4 +++ .../in_app_purchase_ios/example/lib/main.dart | 27 +++++++++++++++++-- .../in_app_purchase_ios/pubspec.yaml | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index d46c124b9011..480426cf5e54 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.0+1 + +* Added a "Restore purchases" button to conform to Apple's StoreKit guidelines on [restoring products](https://developer.apple.com/documentation/storekit/in-app_purchase/restoring_purchased_products?language=objc); + ## 0.1.0 * Initial open-source release. \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart index d871ce0bcbd5..5452f5a0ee83 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart @@ -110,8 +110,6 @@ class _MyAppState extends State<_MyApp> { return; } - await _iapIosPlatform.restorePurchases(); - List consumables = await ConsumableStore.load(); setState(() { _isAvailable = isAvailable; @@ -139,6 +137,7 @@ class _MyAppState extends State<_MyApp> { _buildConnectionCheckTile(), _buildProductList(), _buildConsumableBox(), + _buildRestoreButton(), ], ), ); @@ -310,6 +309,30 @@ class _MyAppState extends State<_MyApp> { ])); } + Widget _buildRestoreButton() { + if (_loading) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + child: Text('Restore purchases'), + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + primary: Colors.white, + ), + onPressed: () => _iapIosPlatform.restorePurchases(), + ), + ], + ), + ); + } + Future consume(String id) async { await ConsumableStore.consume(id); final List consumables = await ConsumableStore.load(); diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 8207b03262d0..7a3885a8a3b1 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.0 +version: 0.1.0+1 environment: sdk: ">=2.12.0 <3.0.0" From af7746a955c581e1cb1df051baccab6c551b33db Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 3 Jun 2021 01:37:24 +0200 Subject: [PATCH 010/364] [quick_actions] Optimised UI test to prevent flaky CI failures (#4003) Speculative fix for frequently flaky test. --- .../example/ios/RunnerUITests/RunnerUITests.m | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/quick_actions/quick_actions/example/ios/RunnerUITests/RunnerUITests.m b/packages/quick_actions/quick_actions/example/ios/RunnerUITests/RunnerUITests.m index 9991e344fe91..0bad57f886de 100644 --- a/packages/quick_actions/quick_actions/example/ios/RunnerUITests/RunnerUITests.m +++ b/packages/quick_actions/quick_actions/example/ios/RunnerUITests/RunnerUITests.m @@ -11,18 +11,23 @@ @interface RunnerUITests : XCTestCase @end -@implementation RunnerUITests +@implementation RunnerUITests { + XCUIApplication *_exampleApp; +} - (void)setUp { [super setUp]; self.continueAfterFailure = NO; + _exampleApp = [[XCUIApplication alloc] init]; } -- (void)testQuickActionWithFreshStart { - XCUIApplication *app = [[XCUIApplication alloc] init]; - [app launch]; - [app terminate]; +- (void)tearDown { + [super tearDown]; + [_exampleApp terminate]; + _exampleApp = nil; +} +- (void)testQuickActionWithFreshStart { XCUIApplication *springboard = [[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"]; XCUIElement *quickActionsAppIcon = springboard.icons[@"quick_actions_example"]; @@ -42,24 +47,21 @@ - (void)testQuickActionWithFreshStart { [actionTwo tap]; - XCUIElement *actionTwoConfirmation = app.otherElements[@"action_two"]; + XCUIElement *actionTwoConfirmation = _exampleApp.otherElements[@"action_two"]; if (![actionTwoConfirmation waitForExistenceWithTimeout:kElementWaitingTime]) { os_log_error(OS_LOG_DEFAULT, "%@", springboard.debugDescription); XCTFail(@"Failed due to not able to find the actionTwoConfirmation in the app with %@ seconds", @(kElementWaitingTime)); } XCTAssertTrue(actionTwoConfirmation.exists); - - [app terminate]; } - (void)testQuickActionWhenAppIsInBackground { - XCUIApplication *app = [[XCUIApplication alloc] init]; - [app launch]; + [_exampleApp launch]; - XCUIElement *actionsReady = app.otherElements[@"actions ready"]; + XCUIElement *actionsReady = _exampleApp.otherElements[@"actions ready"]; if (![actionsReady waitForExistenceWithTimeout:kElementWaitingTime]) { - os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + os_log_error(OS_LOG_DEFAULT, "%@", _exampleApp.debugDescription); XCTFail(@"Failed due to not able to find the actionsReady in the app with %@ seconds", @(kElementWaitingTime)); } @@ -85,15 +87,13 @@ - (void)testQuickActionWhenAppIsInBackground { [actionOne tap]; - XCUIElement *actionOneConfirmation = app.otherElements[@"action_one"]; + XCUIElement *actionOneConfirmation = _exampleApp.otherElements[@"action_one"]; if (![actionOneConfirmation waitForExistenceWithTimeout:kElementWaitingTime]) { os_log_error(OS_LOG_DEFAULT, "%@", springboard.debugDescription); XCTFail(@"Failed due to not able to find the actionOneConfirmation in the app with %@ seconds", @(kElementWaitingTime)); } XCTAssertTrue(actionOneConfirmation.exists); - - [app terminate]; } @end From af4fe1b1b180ff5c63dd5e9f3ddb56ee4515dd13 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 2 Jun 2021 16:44:06 -0700 Subject: [PATCH 011/364] Check in regenerated C++ example registrants (#4004) --- .../example/linux/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/linux/flutter/generated_plugin_registrant.h | 2 ++ .../example/windows/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/windows/flutter/generated_plugin_registrant.h | 2 ++ .../example/linux/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/linux/flutter/generated_plugin_registrant.h | 2 ++ .../example/windows/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/windows/flutter/generated_plugin_registrant.h | 2 ++ .../example/linux/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/linux/flutter/generated_plugin_registrant.h | 2 ++ .../example/windows/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/windows/flutter/generated_plugin_registrant.h | 2 ++ .../example/linux/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/linux/flutter/generated_plugin_registrant.h | 2 ++ .../example/windows/flutter/generated_plugin_registrant.cc | 6 +++++- .../example/windows/flutter/generated_plugin_registrant.h | 2 ++ .../example/linux/flutter/generated_plugin_registrant.cc | 5 +++-- .../example/linux/flutter/generated_plugin_registrant.h | 2 ++ .../example/windows/flutter/generated_plugin_registrant.cc | 2 ++ .../example/windows/flutter/generated_plugin_registrant.h | 2 ++ .../example/linux/flutter/generated_plugin_registrant.cc | 5 +++-- .../example/linux/flutter/generated_plugin_registrant.h | 2 ++ .../example/windows/flutter/generated_plugin_registrant.cc | 2 ++ .../example/windows/flutter/generated_plugin_registrant.h | 2 ++ 24 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.cc b/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.cc index 890de29bbab1..e71a16d23d05 100644 --- a/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void fl_register_plugins(FlPluginRegistry* registry) {} + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.h b/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.h index 9bf7478940c1..e0f0a47bc08f 100644 --- a/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.h +++ b/packages/path_provider/path_provider/example/linux/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.cc b/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.cc index a6177ab0b72b..8b6d4680af38 100644 --- a/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void RegisterPlugins(flutter::PluginRegistry* registry) {} + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.h b/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.h index 9846246b4dac..dc139d85a931 100644 --- a/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.h +++ b/packages/path_provider/path_provider/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.cc b/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.cc index 890de29bbab1..e71a16d23d05 100644 --- a/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void fl_register_plugins(FlPluginRegistry* registry) {} + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.h b/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.h index 9bf7478940c1..e0f0a47bc08f 100644 --- a/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.h +++ b/packages/path_provider/path_provider_linux/example/linux/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.cc b/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.cc index a6177ab0b72b..8b6d4680af38 100644 --- a/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void RegisterPlugins(flutter::PluginRegistry* registry) {} + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.h b/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.h index 9846246b4dac..dc139d85a931 100644 --- a/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.h +++ b/packages/path_provider/path_provider_windows/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.cc b/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.cc index 890de29bbab1..e71a16d23d05 100644 --- a/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void fl_register_plugins(FlPluginRegistry* registry) {} + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.h b/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.h index 9bf7478940c1..e0f0a47bc08f 100644 --- a/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.h +++ b/packages/shared_preferences/shared_preferences/example/linux/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.cc b/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.cc index a6177ab0b72b..8b6d4680af38 100644 --- a/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void RegisterPlugins(flutter::PluginRegistry* registry) {} + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.h b/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.h index 9846246b4dac..dc139d85a931 100644 --- a/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.h +++ b/packages/shared_preferences/shared_preferences/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.cc b/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.cc index 890de29bbab1..e71a16d23d05 100644 --- a/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void fl_register_plugins(FlPluginRegistry* registry) {} + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.h b/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.h index 9bf7478940c1..e0f0a47bc08f 100644 --- a/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.h +++ b/packages/shared_preferences/shared_preferences_linux/example/linux/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.cc b/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.cc index a6177ab0b72b..8b6d4680af38 100644 --- a/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,10 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" -void RegisterPlugins(flutter::PluginRegistry* registry) {} + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.h b/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.h index 9846246b4dac..dc139d85a931 100644 --- a/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.h +++ b/packages/shared_preferences/shared_preferences_windows/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.cc b/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.cc index 36185a63f2fd..f6f23bfe970f 100644 --- a/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.cc @@ -2,13 +2,14 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, - "UrlLauncherPlugin"); + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.h b/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.h index 9bf7478940c1..e0f0a47bc08f 100644 --- a/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.h +++ b/packages/url_launcher/url_launcher/example/linux/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.cc b/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.cc index ddfcf7c328e2..d9fdd53925c5 100644 --- a/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" #include diff --git a/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.h b/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.h index 9846246b4dac..dc139d85a931 100644 --- a/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.h +++ b/packages/url_launcher/url_launcher/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.cc b/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.cc index 36185a63f2fd..f6f23bfe970f 100644 --- a/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.cc @@ -2,13 +2,14 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, - "UrlLauncherPlugin"); + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.h b/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.h index 9bf7478940c1..e0f0a47bc08f 100644 --- a/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.h +++ b/packages/url_launcher/url_launcher_linux/example/linux/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc index ddfcf7c328e2..d9fdd53925c5 100644 --- a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #include "generated_plugin_registrant.h" #include diff --git a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.h b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.h index 9846246b4dac..dc139d85a931 100644 --- a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.h +++ b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.h @@ -2,6 +2,8 @@ // Generated file. Do not edit. // +// clang-format off + #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ From b2dd320e976400ef3451d292d98f54defd84ce69 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 3 Jun 2021 07:42:49 +0200 Subject: [PATCH 012/364] [in_app_purchase] Add "Restore purchases" button to example App (#3945) * Fixed code snippet in README * Added button to restore purchases * Updated CHANGELOG --- .../in_app_purchase/CHANGELOG.md | 5 ++++ .../in_app_purchase/in_app_purchase/README.md | 1 - .../in_app_purchase/example/lib/main.dart | 27 +++++++++++++++++-- .../in_app_purchase/pubspec.yaml | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 0f75d18c8d79..01c66d405960 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.0.3 + +* Added a "Restore purchases" button to conform to Apple's StoreKit guidelines on [restoring products](https://developer.apple.com/documentation/storekit/in-app_purchase/restoring_purchased_products?language=objc); +* Corrected an error in a example snippet displayed in the README.md. + ## 1.0.2 * Fix ignoring "autoConsume" param in "InAppPurchase.instance.buyConsumable". diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index 4e1046386b70..5376834d4d1c 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -136,7 +136,6 @@ void _listenToPurchaseUpdated(List purchaseDetailsList) { _deliverProduct(purchaseDetails); } else { _handleInvalidPurchase(purchaseDetails); - return; } } if (purchaseDetails.pendingCompletePurchase) { diff --git a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart index 17beade3e465..5429a00125ac 100644 --- a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart @@ -114,8 +114,6 @@ class _MyAppState extends State<_MyApp> { return; } - await _inAppPurchase.restorePurchases(); - List consumables = await ConsumableStore.load(); setState(() { _isAvailable = isAvailable; @@ -143,6 +141,7 @@ class _MyAppState extends State<_MyApp> { _buildConnectionCheckTile(), _buildProductList(), _buildConsumableBox(), + _buildRestoreButton(), ], ), ); @@ -337,6 +336,30 @@ class _MyAppState extends State<_MyApp> { ])); } + Widget _buildRestoreButton() { + if (_loading) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + child: Text('Restore purchases'), + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + primary: Colors.white, + ), + onPressed: () => _inAppPurchase.restorePurchases(), + ), + ], + ), + ); + } + Future consume(String id) async { await ConsumableStore.consume(id); final List consumables = await ConsumableStore.load(); diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index aeb7b633bb86..303986c58899 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.2 +version: 1.0.3 environment: sdk: ">=2.12.0 <3.0.0" From 2a236e9fd9bb1a35dd9572763f04faac2351840f Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 3 Jun 2021 10:19:51 -0700 Subject: [PATCH 013/364] Standardize XCTest locations and harnesses (#4005) - Moves XCTest files to the now-standard location - Ensures that the harnesses are called RunnerTests for consistency - Splits the image_picker unit tests out of the UI target into a new unit test target - Moves existing google_sign_in tests into the harness, since they weren't being run. One new test, added since we accidentally stopped compiling the file, was removed since it crashed other tests in the suite (which has non-trivial global state, so fixing it wasn't feasible here; I've follow up on the PR that added the test). --- packages/camera/camera/example/ios/Podfile | 4 +- .../ios/Runner.xcodeproj/project.pbxproj | 54 +++--- .../xcshareddata/xcschemes/Runner.xcscheme | 4 +- .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../CameraFocusTests.m | 0 .../ios/RunnerTests}/CameraPluginTests.m | 0 .../ios/{UnitTests => RunnerTests}/Info.plist | 0 packages/camera/camera/ios/camera.podspec | 4 - .../google_sign_in/example/ios/Podfile | 2 + .../ios/RunnerTests/GoogleSignInTests.m | 150 +++++++++++++- .../ios/Tests/GoogleSignInPluginTest.m | 174 ----------------- .../google_sign_in/ios/google_sign_in.podspec | 5 - .../image_picker/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 183 +++++++++++++++--- .../xcshareddata/xcschemes/Runner.xcscheme | 10 + .../ios/RunnerTests}/ImagePickerPluginTests.m | 0 .../ios/RunnerTests}/ImagePickerTestImages.h | 0 .../ios/RunnerTests}/ImagePickerTestImages.m | 0 .../ios/RunnerTests}/ImageUtilTests.m | 0 .../example/ios/RunnerTests}/Info.plist | 0 .../ios/RunnerTests}/MetaDataUtilTests.m | 0 .../ios/RunnerTests}/PhotoAssetUtilTests.m | 0 .../image_picker/ios/image_picker.podspec | 4 - .../in_app_purchase_ios/example/ios/Podfile | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 98 +++++----- .../xcshareddata/xcschemes/Runner.xcscheme | 4 +- .../RunnerTests/InAppPurchasePluginTests.m} | 0 .../Info.plist | 0 .../ios/RunnerTests/PaymentQueueTests.m} | 0 .../RunnerTests/ProductRequestHandlerTests.m} | 0 .../Tests => example/ios/RunnerTests}/Stubs.h | 0 .../Tests => example/ios/RunnerTests}/Stubs.m | 0 .../ios/RunnerTests/TranslatorTests.m} | 0 .../ios/in_app_purchase_ios.podspec | 5 - packages/local_auth/example/ios/Podfile | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 56 +++--- .../xcshareddata/xcschemes/Runner.xcscheme | 4 +- .../RunnerTests}/FLTLocalAuthPluginTests.m | 0 .../example/ios/RunnerTests/Info.plist | 22 +++ .../ios/Runner.xcodeproj/project.pbxproj | 18 +- .../FLTWKNavigationDelegateTests.m | 0 .../ios/RunnerTests}/FLTWebViewTests.m | 0 .../ios/webview_flutter.podspec | 5 - 43 files changed, 476 insertions(+), 345 deletions(-) create mode 100644 packages/camera/camera/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename packages/camera/camera/example/ios/{UnitTests => RunnerTests}/CameraFocusTests.m (100%) rename packages/camera/camera/{ios/Tests => example/ios/RunnerTests}/CameraPluginTests.m (100%) rename packages/camera/camera/example/ios/{UnitTests => RunnerTests}/Info.plist (100%) delete mode 100644 packages/google_sign_in/google_sign_in/ios/Tests/GoogleSignInPluginTest.m rename packages/image_picker/image_picker/{ios/Tests => example/ios/RunnerTests}/ImagePickerPluginTests.m (100%) rename packages/image_picker/image_picker/{ios/Tests => example/ios/RunnerTests}/ImagePickerTestImages.h (100%) rename packages/image_picker/image_picker/{ios/Tests => example/ios/RunnerTests}/ImagePickerTestImages.m (100%) rename packages/image_picker/image_picker/{ios/Tests => example/ios/RunnerTests}/ImageUtilTests.m (100%) rename packages/{local_auth/example/ios/XCTests => image_picker/image_picker/example/ios/RunnerTests}/Info.plist (100%) rename packages/image_picker/image_picker/{ios/Tests => example/ios/RunnerTests}/MetaDataUtilTests.m (100%) rename packages/image_picker/image_picker/{ios/Tests => example/ios/RunnerTests}/PhotoAssetUtilTests.m (100%) rename packages/in_app_purchase/in_app_purchase_ios/{ios/Tests/InAppPurchasePluginTest.m => example/ios/RunnerTests/InAppPurchasePluginTests.m} (100%) rename packages/in_app_purchase/in_app_purchase_ios/example/ios/{in_app_purchase_pluginTests => RunnerTests}/Info.plist (100%) rename packages/in_app_purchase/in_app_purchase_ios/{ios/Tests/PaymentQueueTest.m => example/ios/RunnerTests/PaymentQueueTests.m} (100%) rename packages/in_app_purchase/in_app_purchase_ios/{ios/Tests/ProductRequestHandlerTest.m => example/ios/RunnerTests/ProductRequestHandlerTests.m} (100%) rename packages/in_app_purchase/in_app_purchase_ios/{ios/Tests => example/ios/RunnerTests}/Stubs.h (100%) rename packages/in_app_purchase/in_app_purchase_ios/{ios/Tests => example/ios/RunnerTests}/Stubs.m (100%) rename packages/in_app_purchase/in_app_purchase_ios/{ios/Tests/TranslatorTest.m => example/ios/RunnerTests/TranslatorTests.m} (100%) rename packages/local_auth/{ios/Tests => example/ios/RunnerTests}/FLTLocalAuthPluginTests.m (100%) create mode 100644 packages/local_auth/example/ios/RunnerTests/Info.plist rename packages/webview_flutter/{ios/Tests => example/ios/RunnerTests}/FLTWKNavigationDelegateTests.m (100%) rename packages/webview_flutter/{ios/Tests => example/ios/RunnerTests}/FLTWebViewTests.m (100%) diff --git a/packages/camera/camera/example/ios/Podfile b/packages/camera/camera/example/ios/Podfile index 884573b376e8..5bc7b7e85717 100644 --- a/packages/camera/camera/example/ios/Podfile +++ b/packages/camera/camera/example/ios/Podfile @@ -29,8 +29,8 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - - target 'UnitTests' do + + target 'RunnerTests' do platform :ios, '9.0' inherit! :search_paths # Pods for testing diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index d39ed6a65a01..1873cfb794cd 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,16 +7,16 @@ objects = { /* Begin PBXBuildFile section */ - 01010359265BEB94FD7CE839 /* libPods-UnitTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CCBF0769BA2C53F6AED0F17 /* libPods-UnitTests.a */; }; 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB766A2665316900CE5A93 /* CameraFocusTests.m */; }; - 03BB767326653ABE00CE5A93 /* CameraPluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 334733EA2668111C00DCC49E /* CameraPluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A513685080F868CF2695CE75 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */; }; D065CD815D405ECB22FB1BBA /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */; }; /* End PBXBuildFile section */ @@ -44,15 +44,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 03BB76682665316900CE5A93 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 03BB76682665316900CE5A93 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 03BB766A2665316900CE5A93 /* CameraFocusTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraFocusTests.m; sourceTree = ""; }; 03BB766C2665316900CE5A93 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CameraPluginTests.m; path = ../../../ios/Tests/CameraPluginTests.m; sourceTree = ""; }; + 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraPluginTests.m; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 6CCBF0769BA2C53F6AED0F17 /* libPods-UnitTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-UnitTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 40D9DDFB3787960D28DF3FB3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -66,8 +67,7 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - A903DC9BC9D1CB89BD4FB3CB /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = ""; }; - C2D350ADCDFC81FCB0D6F12C /* Pods-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.release.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.release.xcconfig"; sourceTree = ""; }; + D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,7 +75,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 01010359265BEB94FD7CE839 /* libPods-UnitTests.a in Frameworks */, + A513685080F868CF2695CE75 /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,21 +90,21 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 03BB76692665316900CE5A93 /* UnitTests */ = { + 03BB76692665316900CE5A93 /* RunnerTests */ = { isa = PBXGroup; children = ( 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */, 03BB766A2665316900CE5A93 /* CameraFocusTests.m */, 03BB766C2665316900CE5A93 /* Info.plist */, ); - path = UnitTests; + path = RunnerTests; sourceTree = ""; }; 78D1009194BD06C03BED950D /* Frameworks */ = { isa = PBXGroup; children = ( 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */, - 6CCBF0769BA2C53F6AED0F17 /* libPods-UnitTests.a */, + 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -125,7 +125,7 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - 03BB76692665316900CE5A93 /* UnitTests */, + 03BB76692665316900CE5A93 /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, FD386F00E98D73419C929072 /* Pods */, 78D1009194BD06C03BED950D /* Frameworks */, @@ -136,7 +136,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 03BB76682665316900CE5A93 /* UnitTests.xctest */, + 03BB76682665316900CE5A93 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -170,8 +170,8 @@ children = ( 8F7D83D0CFC9B51065F87CE1 /* Pods-Runner.debug.xcconfig */, A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */, - A903DC9BC9D1CB89BD4FB3CB /* Pods-UnitTests.debug.xcconfig */, - C2D350ADCDFC81FCB0D6F12C /* Pods-UnitTests.release.xcconfig */, + 40D9DDFB3787960D28DF3FB3 /* Pods-RunnerTests.debug.xcconfig */, + D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -179,9 +179,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 03BB76672665316900CE5A93 /* UnitTests */ = { + 03BB76672665316900CE5A93 /* RunnerTests */ = { isa = PBXNativeTarget; - buildConfigurationList = 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "UnitTests" */; + buildConfigurationList = 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( 604FC00FF5713F40F2A4441D /* [CP] Check Pods Manifest.lock */, 03BB76642665316900CE5A93 /* Sources */, @@ -193,9 +193,9 @@ dependencies = ( 03BB766E2665316900CE5A93 /* PBXTargetDependency */, ); - name = UnitTests; + name = RunnerTests; productName = camera_exampleTests; - productReference = 03BB76682665316900CE5A93 /* UnitTests.xctest */; + productReference = 03BB76682665316900CE5A93 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { @@ -253,7 +253,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 03BB76672665316900CE5A93 /* UnitTests */, + 03BB76672665316900CE5A93 /* RunnerTests */, ); }; /* End PBXProject section */ @@ -331,7 +331,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-UnitTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -360,7 +360,7 @@ buildActionMask = 2147483647; files = ( 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, - 03BB767326653ABE00CE5A93 /* CameraPluginTests.m in Sources */, + 334733EA2668111C00DCC49E /* CameraPluginTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -406,7 +406,7 @@ /* Begin XCBuildConfiguration section */ 03BB766F2665316900CE5A93 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A903DC9BC9D1CB89BD4FB3CB /* Pods-UnitTests.debug.xcconfig */; + baseConfigurationReference = 40D9DDFB3787960D28DF3FB3 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -417,7 +417,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = UnitTests/Info.plist; + INFOPLIST_FILE = RunnerTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -431,7 +431,7 @@ }; 03BB76702665316900CE5A93 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C2D350ADCDFC81FCB0D6F12C /* Pods-UnitTests.release.xcconfig */; + baseConfigurationReference = D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -442,7 +442,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = UnitTests/Info.plist; + INFOPLIST_FILE = RunnerTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; @@ -606,7 +606,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "UnitTests" */ = { + 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 03BB766F2665316900CE5A93 /* Debug */, diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index d9bece2dd771..1447e08231be 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -42,8 +42,8 @@ diff --git a/packages/camera/camera/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/camera/camera/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/camera/camera/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/camera/camera/example/ios/UnitTests/CameraFocusTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m similarity index 100% rename from packages/camera/camera/example/ios/UnitTests/CameraFocusTests.m rename to packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m diff --git a/packages/camera/camera/ios/Tests/CameraPluginTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraPluginTests.m similarity index 100% rename from packages/camera/camera/ios/Tests/CameraPluginTests.m rename to packages/camera/camera/example/ios/RunnerTests/CameraPluginTests.m diff --git a/packages/camera/camera/example/ios/UnitTests/Info.plist b/packages/camera/camera/example/ios/RunnerTests/Info.plist similarity index 100% rename from packages/camera/camera/example/ios/UnitTests/Info.plist rename to packages/camera/camera/example/ios/RunnerTests/Info.plist diff --git a/packages/camera/camera/ios/camera.podspec b/packages/camera/camera/ios/camera.podspec index 960f102e7706..4f9955311fb9 100644 --- a/packages/camera/camera/ios/camera.podspec +++ b/packages/camera/camera/ios/camera.podspec @@ -19,8 +19,4 @@ A Flutter plugin to use the camera from your Flutter app. s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } - - s.test_spec 'Tests' do |test_spec| - test_spec.source_files = 'Tests/**/*' - end end diff --git a/packages/google_sign_in/google_sign_in/example/ios/Podfile b/packages/google_sign_in/google_sign_in/example/ios/Podfile index 3924e59aa0f9..60e9fb54baa5 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Podfile +++ b/packages/google_sign_in/google_sign_in/example/ios/Podfile @@ -31,6 +31,8 @@ target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths + + pod 'OCMock','3.5' end end diff --git a/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m index e96a7abe7715..adbf61326c8d 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m +++ b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m @@ -2,17 +2,155 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -@import google_sign_in; +@import Flutter; + @import XCTest; +@import google_sign_in; +@import GoogleSignIn; + +// OCMock library doesn't generate a valid modulemap. +#import + +@interface FLTGoogleSignInPluginTest : XCTestCase + +@property(strong, nonatomic) NSObject *mockBinaryMessenger; +@property(strong, nonatomic) NSObject *mockPluginRegistrar; +@property(strong, nonatomic) FLTGoogleSignInPlugin *plugin; +@property(strong, nonatomic) GIDSignIn *mockSharedInstance; -@interface GoogleSignInTests : XCTestCase @end -@implementation GoogleSignInTests +@implementation FLTGoogleSignInPluginTest + +- (void)setUp { + [super setUp]; + self.mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); + self.mockPluginRegistrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); + self.mockSharedInstance = [OCMockObject partialMockForObject:[GIDSignIn sharedInstance]]; + OCMStub(self.mockPluginRegistrar.messenger).andReturn(self.mockBinaryMessenger); + self.plugin = [[FLTGoogleSignInPlugin alloc] init]; + [FLTGoogleSignInPlugin registerWithRegistrar:self.mockPluginRegistrar]; +} + +- (void)tearDown { + [((OCMockObject *)self.mockSharedInstance) stopMocking]; + [super tearDown]; +} + +- (void)testRequestScopesResultErrorIfNotSignedIn { + OCMStub(self.mockSharedInstance.currentUser).andReturn(nil); + + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"requestScopes" + arguments:@{@"scopes" : @[ @"mockScope1" ]}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + __block id result; + [self.plugin handleMethodCall:methodCall + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertEqualObjects([((FlutterError *)result) code], @"sign_in_required"); +} + +- (void)testRequestScopesIfNoMissingScope { + // Mock Google Signin internal calls + GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); + OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + NSArray *requestedScopes = @[ @"mockScope1" ]; + OCMStub(mockUser.grantedScopes).andReturn(requestedScopes); + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"requestScopes" + arguments:@{@"scopes" : requestedScopes}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + __block id result; + [self.plugin handleMethodCall:methodCall + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertTrue([result boolValue]); +} + +- (void)testRequestScopesRequestsIfNotGranted { + // Mock Google Signin internal calls + GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); + OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + NSArray *requestedScopes = @[ @"mockScope1" ]; + OCMStub(mockUser.grantedScopes).andReturn(@[]); + + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"requestScopes" + arguments:@{@"scopes" : requestedScopes}]; + + [self.plugin handleMethodCall:methodCall + result:^(id r){ + }]; + + XCTAssertTrue([self.mockSharedInstance.scopes containsObject:@"mockScope1"]); + OCMVerify([self.mockSharedInstance signIn]); +} + +- (void)testRequestScopesReturnsFalseIfNotGranted { + // Mock Google Signin internal calls + GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); + OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + NSArray *requestedScopes = @[ @"mockScope1" ]; + OCMStub(mockUser.grantedScopes).andReturn(@[]); + + OCMStub([self.mockSharedInstance signIn]).andDo(^(NSInvocation *invocation) { + [((NSObject *)self.plugin) signIn:self.mockSharedInstance + didSignInForUser:mockUser + withError:nil]; + }); + + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"requestScopes" + arguments:@{@"scopes" : requestedScopes}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns false"]; + __block id result; + [self.plugin handleMethodCall:methodCall + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertFalse([result boolValue]); +} + +- (void)testRequestScopesReturnsTrueIfGranted { + // Mock Google Signin internal calls + GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); + OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + NSArray *requestedScopes = @[ @"mockScope1" ]; + NSMutableArray *availableScopes = [NSMutableArray new]; + OCMStub(mockUser.grantedScopes).andReturn(availableScopes); + + OCMStub([self.mockSharedInstance signIn]).andDo(^(NSInvocation *invocation) { + [availableScopes addObject:@"mockScope1"]; + [((NSObject *)self.plugin) signIn:self.mockSharedInstance + didSignInForUser:mockUser + withError:nil]; + }); + + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"requestScopes" + arguments:@{@"scopes" : requestedScopes}]; -- (void)testPlugin { - FLTGoogleSignInPlugin* plugin = [[FLTGoogleSignInPlugin alloc] init]; - XCTAssertNotNil(plugin); + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + __block id result; + [self.plugin handleMethodCall:methodCall + result:^(id r) { + [expectation fulfill]; + result = r; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertTrue([result boolValue]); } @end diff --git a/packages/google_sign_in/google_sign_in/ios/Tests/GoogleSignInPluginTest.m b/packages/google_sign_in/google_sign_in/ios/Tests/GoogleSignInPluginTest.m deleted file mode 100644 index 0affe69280c0..000000000000 --- a/packages/google_sign_in/google_sign_in/ios/Tests/GoogleSignInPluginTest.m +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright 2013 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 Flutter; - -@import XCTest; -@import google_sign_in; -@import GoogleSignIn; - -// OCMock library doesn't generate a valid modulemap. -#import - -@interface FLTGoogleSignInPluginTest : XCTestCase - -@property(strong, nonatomic) NSObject *mockBinaryMessenger; -@property(strong, nonatomic) NSObject *mockPluginRegistrar; -@property(strong, nonatomic) FLTGoogleSignInPlugin *plugin; -@property(strong, nonatomic) GIDSignIn *mockSharedInstance; - -@end - -@implementation FLTGoogleSignInPluginTest - -- (void)setUp { - [super setUp]; - self.mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); - self.mockPluginRegistrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); - self.mockSharedInstance = [OCMockObject partialMockForObject:[GIDSignIn sharedInstance]]; - OCMStub(self.mockPluginRegistrar.messenger).andReturn(self.mockBinaryMessenger); - self.plugin = [[FLTGoogleSignInPlugin alloc] init]; - [FLTGoogleSignInPlugin registerWithRegistrar:self.mockPluginRegistrar]; -} - -- (void)tearDown { - [((OCMockObject *)self.mockSharedInstance) stopMocking]; - [super tearDown]; -} - -- (void)testRequestScopesResultErrorIfNotSignedIn { - OCMStub(self.mockSharedInstance.currentUser).andReturn(nil); - - FlutterMethodCall *methodCall = - [FlutterMethodCall methodCallWithMethodName:@"requestScopes" - arguments:@{@"scopes" : @[ @"mockScope1" ]}]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - __block id result; - [self.plugin handleMethodCall:methodCall - result:^(id r) { - [expectation fulfill]; - result = r; - }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertEqualObjects([((FlutterError *)result) code], @"sign_in_required"); -} - -- (void)testRequestScopesIfNoMissingScope { - // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); - NSArray *requestedScopes = @[ @"mockScope1" ]; - OCMStub(mockUser.grantedScopes).andReturn(requestedScopes); - FlutterMethodCall *methodCall = - [FlutterMethodCall methodCallWithMethodName:@"requestScopes" - arguments:@{@"scopes" : requestedScopes}]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - __block id result; - [self.plugin handleMethodCall:methodCall - result:^(id r) { - [expectation fulfill]; - result = r; - }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertTrue([result boolValue]); -} - -- (void)testRequestScopesRequestsIfNotGranted { - // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); - NSArray *requestedScopes = @[ @"mockScope1" ]; - OCMStub(mockUser.grantedScopes).andReturn(@[]); - - FlutterMethodCall *methodCall = - [FlutterMethodCall methodCallWithMethodName:@"requestScopes" - arguments:@{@"scopes" : requestedScopes}]; - - [self.plugin handleMethodCall:methodCall - result:^(id r){ - }]; - - XCTAssertTrue([self.mockSharedInstance.scopes containsObject:@"mockScope1"]); - OCMVerify([self.mockSharedInstance signIn]); -} - -- (void)testRequestScopesReturnsFalseIfNotGranted { - // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); - NSArray *requestedScopes = @[ @"mockScope1" ]; - OCMStub(mockUser.grantedScopes).andReturn(@[]); - - OCMStub([self.mockSharedInstance signIn]).andDo(^(NSInvocation *invocation) { - [((NSObject *)self.plugin) signIn:self.mockSharedInstance - didSignInForUser:mockUser - withError:nil]; - }); - - FlutterMethodCall *methodCall = - [FlutterMethodCall methodCallWithMethodName:@"requestScopes" - arguments:@{@"scopes" : requestedScopes}]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns false"]; - __block id result; - [self.plugin handleMethodCall:methodCall - result:^(id r) { - [expectation fulfill]; - result = r; - }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertFalse([result boolValue]); -} - -- (void)testRequestScopesReturnsTrueIfGranted { - // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); - NSArray *requestedScopes = @[ @"mockScope1" ]; - NSMutableArray *availableScopes = [NSMutableArray new]; - OCMStub(mockUser.grantedScopes).andReturn(availableScopes); - - OCMStub([self.mockSharedInstance signIn]).andDo(^(NSInvocation *invocation) { - [availableScopes addObject:@"mockScope1"]; - [((NSObject *)self.plugin) signIn:self.mockSharedInstance - didSignInForUser:mockUser - withError:nil]; - }); - - FlutterMethodCall *methodCall = - [FlutterMethodCall methodCallWithMethodName:@"requestScopes" - arguments:@{@"scopes" : requestedScopes}]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - __block id result; - [self.plugin handleMethodCall:methodCall - result:^(id r) { - [expectation fulfill]; - result = r; - }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertTrue([result boolValue]); -} - -- (void)testHostedDomainIfMissed { - FlutterMethodCall *methodCall = - [FlutterMethodCall methodCallWithMethodName:@"init" - arguments:@{ - @"signInOption" : @"SignInOption.standard", - @"hostedDomain" : [NSNull null], - }]; - - XCTestExpectation *expectation = - [self expectationWithDescription:@"expect hostedDomain equals nil"]; - [self.plugin handleMethodCall:methodCall - result:^(id r) { - [expectation fulfill]; - }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertTrue([self.mockSharedInstance.hostedDomain == nil]); -} - -@end diff --git a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec index 38ce53c6e0c2..bf0b75f2957d 100644 --- a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec +++ b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec @@ -20,9 +20,4 @@ Enables Google Sign-In in Flutter apps. s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } - - s.test_spec 'Tests' do |test_spec| - test_spec.source_files = 'Tests/**/*' - test_spec.dependency 'OCMock','3.5' - end end diff --git a/packages/image_picker/image_picker/example/ios/Podfile b/packages/image_picker/image_picker/example/ios/Podfile index 48f7bbc93cb5..75efae48b439 100644 --- a/packages/image_picker/image_picker/example/ios/Podfile +++ b/packages/image_picker/image_picker/example/ios/Podfile @@ -30,6 +30,9 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end target 'RunnerUITests' do inherit! :search_paths end diff --git a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj index 4ea0e7449d0a..ef315658277b 100644 --- a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,27 +7,36 @@ objects = { /* Begin PBXBuildFile section */ + 334733FC266813EE00DCC49E /* ImageUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */; }; + 334733FD266813F100DCC49E /* MetaDataUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 680049252280D736006DD6AB /* MetaDataUtilTests.m */; }; + 334733FE266813F400DCC49E /* PhotoAssetUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68F4B463228B3AB500C25614 /* PhotoAssetUtilTests.m */; }; + 334733FF266813FA00DCC49E /* ImagePickerTestImages.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3182342D9D7008449C7 /* ImagePickerTestImages.m */; }; + 33473400266813FD00DCC49E /* ImagePickerPluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68B9AF71243E4B3F00927CE4 /* ImagePickerPluginTests.m */; }; + 3A72BAD3FAE6E0FA9D80826B /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 35AE65F25E0B8C8214D8372B /* libPods-RunnerTests.a */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 56E9C6956BC15C647C89EB23 /* libPods-RunnerUITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A908FAEEA2A9B26D903C09C5 /* libPods-RunnerUITests.a */; }; 5C9513011EC38BD300040975 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C9513001EC38BD300040975 /* GeneratedPluginRegistrant.m */; }; - 680049262280D736006DD6AB /* MetaDataUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 680049252280D736006DD6AB /* MetaDataUtilTests.m */; }; 680049382280F2B9006DD6AB /* pngImage.png in Resources */ = {isa = PBXBuildFile; fileRef = 680049352280F2B8006DD6AB /* pngImage.png */; }; 680049392280F2B9006DD6AB /* jpgImage.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 680049362280F2B8006DD6AB /* jpgImage.jpg */; }; 6801C8392555D726009DAF8D /* ImagePickerFromGalleryUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6801C8382555D726009DAF8D /* ImagePickerFromGalleryUITests.m */; }; - 68B9AF72243E4B3F00927CE4 /* ImagePickerPluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68B9AF71243E4B3F00927CE4 /* ImagePickerPluginTests.m */; }; - 68F4B464228B3AB500C25614 /* PhotoAssetUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68F4B463228B3AB500C25614 /* PhotoAssetUtilTests.m */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 9FC8F0E9229FA49E00C8D58F /* gifImage.gif in Resources */ = {isa = PBXBuildFile; fileRef = 9FC8F0E8229FA49E00C8D58F /* gifImage.gif */; }; 9FC8F0EC229FA68500C8D58F /* gifImage.gif in Resources */ = {isa = PBXBuildFile; fileRef = 9FC8F0E8229FA49E00C8D58F /* gifImage.gif */; }; - 9FC8F0EE229FB90B00C8D58F /* ImageUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */; }; BE7AEE7926403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = BE7AEE7826403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m */; }; F4F7A436CCA4BF276270A3AE /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = EC32F6993F4529982D9519F1 /* libPods-Runner.a */; }; - F78AF3192342D9D7008449C7 /* ImagePickerTestImages.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3182342D9D7008449C7 /* ImagePickerTestImages.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 334733F72668136400DCC49E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; 6801C83B2555D726009DAF8D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -58,21 +67,25 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0C7B151765FD4249454C49AD /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 15BE72415096DFE5D077E563 /* Pods-RunnerUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.debug.xcconfig"; sourceTree = ""; }; + 334733F22668136400DCC49E /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 334733F62668136400DCC49E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 35AE65F25E0B8C8214D8372B /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 515A7EC9B4C971C01E672CF8 /* Pods-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.release.xcconfig"; sourceTree = ""; }; 5A9D31B91557877A0E8EF3E7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 5C9512FF1EC38BD300040975 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 5C9513001EC38BD300040975 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 680049252280D736006DD6AB /* MetaDataUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = MetaDataUtilTests.m; path = ../../../ios/Tests/MetaDataUtilTests.m; sourceTree = ""; }; + 680049252280D736006DD6AB /* MetaDataUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MetaDataUtilTests.m; sourceTree = ""; }; 680049352280F2B8006DD6AB /* pngImage.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pngImage.png; sourceTree = ""; }; 680049362280F2B8006DD6AB /* jpgImage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = jpgImage.jpg; sourceTree = ""; }; 6801632E632668F4349764C9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 6801C8362555D726009DAF8D /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6801C8382555D726009DAF8D /* ImagePickerFromGalleryUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImagePickerFromGalleryUITests.m; sourceTree = ""; }; 6801C83A2555D726009DAF8D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 68B9AF71243E4B3F00927CE4 /* ImagePickerPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = ImagePickerPluginTests.m; path = ../../../ios/Tests/ImagePickerPluginTests.m; sourceTree = ""; }; - 68F4B463228B3AB500C25614 /* PhotoAssetUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PhotoAssetUtilTests.m; path = ../../../ios/Tests/PhotoAssetUtilTests.m; sourceTree = ""; }; + 68B9AF71243E4B3F00927CE4 /* ImagePickerPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ImagePickerPluginTests.m; sourceTree = ""; }; + 68F4B463228B3AB500C25614 /* PhotoAssetUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PhotoAssetUtilTests.m; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -85,21 +98,31 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9FC8F0E8229FA49E00C8D58F /* gifImage.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = gifImage.gif; sourceTree = ""; }; - 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ImageUtilTests.m; path = ../../../ios/Tests/ImageUtilTests.m; sourceTree = ""; }; + 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImageUtilTests.m; sourceTree = ""; }; A908FAEEA2A9B26D903C09C5 /* libPods-RunnerUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; BE7AEE6C26403C46006181AA /* RunnerUITestiOS14.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITestiOS14.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; BE7AEE7026403C46006181AA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BE7AEE7826403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImagePickerFromLimitedGalleryUITests.m; sourceTree = ""; }; + DC6FCAAD4E7580C9B3C2E21D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; EC32F6993F4529982D9519F1 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - F78AF3172342D9D7008449C7 /* ImagePickerTestImages.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = ImagePickerTestImages.h; path = ../../../ios/Tests/ImagePickerTestImages.h; sourceTree = ""; }; - F78AF3182342D9D7008449C7 /* ImagePickerTestImages.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ImagePickerTestImages.m; path = ../../../ios/Tests/ImagePickerTestImages.m; sourceTree = ""; }; + F78AF3172342D9D7008449C7 /* ImagePickerTestImages.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ImagePickerTestImages.h; sourceTree = ""; }; + F78AF3182342D9D7008449C7 /* ImagePickerTestImages.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImagePickerTestImages.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 334733EF2668136400DCC49E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A72BAD3FAE6E0FA9D80826B /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6801C8332555D726009DAF8D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 56E9C6956BC15C647C89EB23 /* libPods-RunnerUITests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -121,6 +144,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 334733F32668136400DCC49E /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */, + 680049252280D736006DD6AB /* MetaDataUtilTests.m */, + 68F4B463228B3AB500C25614 /* PhotoAssetUtilTests.m */, + F78AF3172342D9D7008449C7 /* ImagePickerTestImages.h */, + F78AF3182342D9D7008449C7 /* ImagePickerTestImages.m */, + 68B9AF71243E4B3F00927CE4 /* ImagePickerPluginTests.m */, + 334733F62668136400DCC49E /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; 680049282280E33D006DD6AB /* TestImages */ = { isa = PBXGroup; children = ( @@ -135,12 +172,6 @@ isa = PBXGroup; children = ( 6801C8382555D726009DAF8D /* ImagePickerFromGalleryUITests.m */, - 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */, - 680049252280D736006DD6AB /* MetaDataUtilTests.m */, - 68F4B463228B3AB500C25614 /* PhotoAssetUtilTests.m */, - F78AF3172342D9D7008449C7 /* ImagePickerTestImages.h */, - F78AF3182342D9D7008449C7 /* ImagePickerTestImages.m */, - 68B9AF71243E4B3F00927CE4 /* ImagePickerPluginTests.m */, 6801C83A2555D726009DAF8D /* Info.plist */, ); path = RunnerUITests; @@ -153,6 +184,8 @@ 5A9D31B91557877A0E8EF3E7 /* Pods-Runner.release.xcconfig */, 15BE72415096DFE5D077E563 /* Pods-RunnerUITests.debug.xcconfig */, 515A7EC9B4C971C01E672CF8 /* Pods-RunnerUITests.release.xcconfig */, + DC6FCAAD4E7580C9B3C2E21D /* Pods-RunnerTests.debug.xcconfig */, + 0C7B151765FD4249454C49AD /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -174,6 +207,7 @@ 680049282280E33D006DD6AB /* TestImages */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + 334733F32668136400DCC49E /* RunnerTests */, 6801C8372555D726009DAF8D /* RunnerUITests */, BE7AEE6D26403C46006181AA /* RunnerUITestiOS14 */, 97C146EF1CF9000F007C117D /* Products */, @@ -188,6 +222,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 6801C8362555D726009DAF8D /* RunnerUITests.xctest */, BE7AEE6C26403C46006181AA /* RunnerUITestiOS14.xctest */, + 334733F22668136400DCC49E /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -230,6 +265,7 @@ children = ( EC32F6993F4529982D9519F1 /* libPods-Runner.a */, A908FAEEA2A9B26D903C09C5 /* libPods-RunnerUITests.a */, + 35AE65F25E0B8C8214D8372B /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -237,6 +273,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 334733F12668136400DCC49E /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 334733F92668136400DCC49E /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + B8739A4353234497CF76B597 /* [CP] Check Pods Manifest.lock */, + 334733EE2668136400DCC49E /* Sources */, + 334733EF2668136400DCC49E /* Frameworks */, + 334733F02668136400DCC49E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 334733F82668136400DCC49E /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 334733F22668136400DCC49E /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 6801C8352555D726009DAF8D /* RunnerUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 6801C83F2555D726009DAF8D /* Build configuration list for PBXNativeTarget "RunnerUITests" */; @@ -305,6 +360,11 @@ LastUpgradeCheck = 1100; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { + 334733F12668136400DCC49E = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 6801C8352555D726009DAF8D = { CreatedOnToolsVersion = 11.7; ProvisioningStyle = Automatic; @@ -339,6 +399,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 334733F12668136400DCC49E /* RunnerTests */, 6801C8352555D726009DAF8D /* RunnerUITests */, BE7AEE6B26403C46006181AA /* RunnerUITestiOS14 */, ); @@ -346,6 +407,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 334733F02668136400DCC49E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6801C8342555D726009DAF8D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -445,19 +513,48 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + B8739A4353234497CF76B597 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 334733EE2668136400DCC49E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 334733FD266813F100DCC49E /* MetaDataUtilTests.m in Sources */, + 334733FF266813FA00DCC49E /* ImagePickerTestImages.m in Sources */, + 334733FC266813EE00DCC49E /* ImageUtilTests.m in Sources */, + 33473400266813FD00DCC49E /* ImagePickerPluginTests.m in Sources */, + 334733FE266813F400DCC49E /* PhotoAssetUtilTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6801C8322555D726009DAF8D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6801C8392555D726009DAF8D /* ImagePickerFromGalleryUITests.m in Sources */, - 9FC8F0EE229FB90B00C8D58F /* ImageUtilTests.m in Sources */, - F78AF3192342D9D7008449C7 /* ImagePickerTestImages.m in Sources */, - 680049262280D736006DD6AB /* MetaDataUtilTests.m in Sources */, - 68B9AF72243E4B3F00927CE4 /* ImagePickerPluginTests.m in Sources */, - 68F4B464228B3AB500C25614 /* PhotoAssetUtilTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -482,6 +579,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 334733F82668136400DCC49E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 334733F72668136400DCC49E /* PBXContainerItemProxy */; + }; 6801C83C2555D726009DAF8D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -514,6 +616,34 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 334733FA2668136400DCC49E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DC6FCAAD4E7580C9B3C2E21D /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 334733FB2668136400DCC49E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C7B151765FD4249454C49AD /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; 6801C83D2555D726009DAF8D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; @@ -763,6 +893,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 334733F92668136400DCC49E /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 334733FA2668136400DCC49E /* Debug */, + 334733FB2668136400DCC49E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 6801C83F2555D726009DAF8D /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index b1f7ff22fdde..b100e5cd18d7 100755 --- a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -57,6 +57,16 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - - s.test_spec 'Tests' do |test_spec| - test_spec.source_files = 'Tests/**/*' - end end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile index 7079e94dc672..ae8750242a6e 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile @@ -30,7 +30,7 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'in_app_purchase_pluginTests' do + target 'RunnerTests' do inherit! :search_paths # Matches in_app_purchase test_spec dependency. diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj index 3f2cd3d7e434..90a7f3e86830 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -10,8 +10,8 @@ 0FFCF66105590202CD84C7AA /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1630769A874F9381BC761FE1 /* libPods-Runner.a */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 688DE35121F2A5A100EA2684 /* TranslatorTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 688DE35021F2A5A100EA2684 /* TranslatorTest.m */; }; - 6896B34621E9363700D37AEF /* ProductRequestHandlerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34521E9363700D37AEF /* ProductRequestHandlerTest.m */; }; + 688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 688DE35021F2A5A100EA2684 /* TranslatorTests.m */; }; + 6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */; }; 6896B34C21EEB4B800D37AEF /* Stubs.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34B21EEB4B800D37AEF /* Stubs.m */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; @@ -19,9 +19,9 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A5279298219369C600FF69E6 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5279297219369C600FF69E6 /* StoreKit.framework */; }; - A59001A721E69658004A3E5E /* InAppPurchasePluginTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A59001A621E69658004A3E5E /* InAppPurchasePluginTest.m */; }; - F78AF3142342BC89008449C7 /* PaymentQueueTest.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3132342BC89008449C7 /* PaymentQueueTest.m */; }; - FF1D041E5E26858D1AF300BC /* libPods-in_app_purchase_pluginTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 28CC9057029D80DB8A500E56 /* libPods-in_app_purchase_pluginTests.a */; }; + A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */; }; + AB7252348F077C046D6617D3 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */; }; + F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3132342BC89008449C7 /* PaymentQueueTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,18 +48,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 1630769A874F9381BC761FE1 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 1F1978CCF9BBD9FE5606B43A /* Pods-in_app_purchase_pluginTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-in_app_purchase_pluginTests.release.xcconfig"; path = "Target Support Files/Pods-in_app_purchase_pluginTests/Pods-in_app_purchase_pluginTests.release.xcconfig"; sourceTree = ""; }; + 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 2550EB3A5A3E749A54ADCA2D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 28CC9057029D80DB8A500E56 /* libPods-in_app_purchase_pluginTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-in_app_purchase_pluginTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 67D5CD73380CB78474FA613C /* Pods-in_app_purchase_pluginTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-in_app_purchase_pluginTests.debug.xcconfig"; path = "Target Support Files/Pods-in_app_purchase_pluginTests/Pods-in_app_purchase_pluginTests.debug.xcconfig"; sourceTree = ""; }; - 688DE35021F2A5A100EA2684 /* TranslatorTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = TranslatorTest.m; path = ../../../ios/Tests/TranslatorTest.m; sourceTree = ""; }; - 6896B34521E9363700D37AEF /* ProductRequestHandlerTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ProductRequestHandlerTest.m; path = ../../../ios/Tests/ProductRequestHandlerTest.m; sourceTree = ""; }; - 6896B34A21EEB4B800D37AEF /* Stubs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = Stubs.h; path = ../../../ios/Tests/Stubs.h; sourceTree = ""; }; - 6896B34B21EEB4B800D37AEF /* Stubs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = Stubs.m; path = ../../../ios/Tests/Stubs.m; sourceTree = ""; }; + 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 688DE35021F2A5A100EA2684 /* TranslatorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TranslatorTests.m; sourceTree = ""; }; + 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ProductRequestHandlerTests.m; sourceTree = ""; }; + 6896B34A21EEB4B800D37AEF /* Stubs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Stubs.h; sourceTree = ""; }; + 6896B34B21EEB4B800D37AEF /* Stubs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Stubs.m; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -72,12 +72,12 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A5279297219369C600FF69E6 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; - A59001A421E69658004A3E5E /* in_app_purchase_pluginTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = in_app_purchase_pluginTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - A59001A621E69658004A3E5E /* InAppPurchasePluginTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = InAppPurchasePluginTest.m; path = ../../../ios/Tests/InAppPurchasePluginTest.m; sourceTree = ""; }; + A59001A421E69658004A3E5E /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InAppPurchasePluginTests.m; sourceTree = ""; }; A59001A821E69658004A3E5E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E4F9651425A612301059769C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; F6E5D5F926131C4800C68BED /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; - F78AF3132342BC89008449C7 /* PaymentQueueTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = PaymentQueueTest.m; path = ../../../ios/Tests/PaymentQueueTest.m; sourceTree = ""; }; + F78AF3132342BC89008449C7 /* PaymentQueueTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PaymentQueueTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -94,7 +94,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FF1D041E5E26858D1AF300BC /* libPods-in_app_purchase_pluginTests.a in Frameworks */, + AB7252348F077C046D6617D3 /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,12 +106,19 @@ children = ( E4F9651425A612301059769C /* Pods-Runner.debug.xcconfig */, 2550EB3A5A3E749A54ADCA2D /* Pods-Runner.release.xcconfig */, - 67D5CD73380CB78474FA613C /* Pods-in_app_purchase_pluginTests.debug.xcconfig */, - 1F1978CCF9BBD9FE5606B43A /* Pods-in_app_purchase_pluginTests.release.xcconfig */, + 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */, + 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */, ); path = Pods; sourceTree = ""; }; + 334733E826680E5900DCC49E /* Temp */ = { + isa = PBXGroup; + children = ( + ); + path = Temp; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -126,9 +133,10 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 334733E826680E5900DCC49E /* Temp */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - A59001A521E69658004A3E5E /* in_app_purchase_pluginTests */, + A59001A521E69658004A3E5E /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, E4DB99639FAD8ADED6B572FC /* Frameworks */, 0B4403AC68C3196AECF5EF89 /* Pods */, @@ -139,7 +147,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - A59001A421E69658004A3E5E /* in_app_purchase_pluginTests.xctest */, + A59001A421E69658004A3E5E /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -169,18 +177,18 @@ name = "Supporting Files"; sourceTree = ""; }; - A59001A521E69658004A3E5E /* in_app_purchase_pluginTests */ = { + A59001A521E69658004A3E5E /* RunnerTests */ = { isa = PBXGroup; children = ( - A59001A621E69658004A3E5E /* InAppPurchasePluginTest.m */, - 6896B34521E9363700D37AEF /* ProductRequestHandlerTest.m */, - F78AF3132342BC89008449C7 /* PaymentQueueTest.m */, A59001A821E69658004A3E5E /* Info.plist */, 6896B34A21EEB4B800D37AEF /* Stubs.h */, 6896B34B21EEB4B800D37AEF /* Stubs.m */, - 688DE35021F2A5A100EA2684 /* TranslatorTest.m */, + A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */, + 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */, + F78AF3132342BC89008449C7 /* PaymentQueueTests.m */, + 688DE35021F2A5A100EA2684 /* TranslatorTests.m */, ); - path = in_app_purchase_pluginTests; + path = RunnerTests; sourceTree = ""; }; E4DB99639FAD8ADED6B572FC /* Frameworks */ = { @@ -188,7 +196,7 @@ children = ( A5279297219369C600FF69E6 /* StoreKit.framework */, 1630769A874F9381BC761FE1 /* libPods-Runner.a */, - 28CC9057029D80DB8A500E56 /* libPods-in_app_purchase_pluginTests.a */, + 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -217,9 +225,9 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; - A59001A321E69658004A3E5E /* in_app_purchase_pluginTests */ = { + A59001A321E69658004A3E5E /* RunnerTests */ = { isa = PBXNativeTarget; - buildConfigurationList = A59001AD21E69658004A3E5E /* Build configuration list for PBXNativeTarget "in_app_purchase_pluginTests" */; + buildConfigurationList = A59001AD21E69658004A3E5E /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( 321E2F5767F55B0A360AA77E /* [CP] Check Pods Manifest.lock */, A59001A021E69658004A3E5E /* Sources */, @@ -231,9 +239,9 @@ dependencies = ( A59001AA21E69658004A3E5E /* PBXTargetDependency */, ); - name = in_app_purchase_pluginTests; - productName = in_app_purchase_pluginTests; - productReference = A59001A421E69658004A3E5E /* in_app_purchase_pluginTests.xctest */; + name = RunnerTests; + productName = RunnerTests; + productReference = A59001A421E69658004A3E5E /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -275,7 +283,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - A59001A321E69658004A3E5E /* in_app_purchase_pluginTests */, + A59001A321E69658004A3E5E /* RunnerTests */, ); }; /* End PBXProject section */ @@ -317,7 +325,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-in_app_purchase_pluginTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -391,10 +399,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F78AF3142342BC89008449C7 /* PaymentQueueTest.m in Sources */, - 6896B34621E9363700D37AEF /* ProductRequestHandlerTest.m in Sources */, - 688DE35121F2A5A100EA2684 /* TranslatorTest.m in Sources */, - A59001A721E69658004A3E5E /* InAppPurchasePluginTest.m in Sources */, + F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */, + 6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */, + 688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */, + A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */, 6896B34C21EEB4B800D37AEF /* Stubs.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -585,7 +593,7 @@ }; A59001AB21E69658004A3E5E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 67D5CD73380CB78474FA613C /* Pods-in_app_purchase_pluginTests.debug.xcconfig */; + baseConfigurationReference = 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -596,11 +604,11 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = in_app_purchase_pluginTests/Info.plist; + INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "sample.changme.in-app-purchase-pluginTests"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -608,7 +616,7 @@ }; A59001AC21E69658004A3E5E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1F1978CCF9BBD9FE5606B43A /* Pods-in_app_purchase_pluginTests.release.xcconfig */; + baseConfigurationReference = 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -619,10 +627,10 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = in_app_purchase_pluginTests/Info.plist; + INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "sample.changme.in-app-purchase-pluginTests"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -649,7 +657,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A59001AD21E69658004A3E5E /* Build configuration list for PBXNativeTarget "in_app_purchase_pluginTests" */ = { + A59001AD21E69658004A3E5E /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( A59001AB21E69658004A3E5E /* Debug */, diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e1fad2d518ae..3bd47ecb9ec0 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -42,8 +42,8 @@ diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/InAppPurchasePluginTest.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/ios/Tests/InAppPurchasePluginTest.m rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/in_app_purchase_pluginTests/Info.plist b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Info.plist similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/example/ios/in_app_purchase_pluginTests/Info.plist rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Info.plist diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/PaymentQueueTest.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/PaymentQueueTests.m similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/ios/Tests/PaymentQueueTest.m rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/PaymentQueueTests.m diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/ProductRequestHandlerTest.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/ProductRequestHandlerTests.m similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/ios/Tests/ProductRequestHandlerTest.m rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/ProductRequestHandlerTests.m diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.h b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.h rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/ios/Tests/Stubs.m rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Tests/TranslatorTest.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m similarity index 100% rename from packages/in_app_purchase/in_app_purchase_ios/ios/Tests/TranslatorTest.m rename to packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec b/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec index c0fcc45a7164..785235336e43 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec @@ -21,9 +21,4 @@ Downloaded by pub (not CocoaPods). s.dependency 'Flutter' s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS' => 'armv7 arm64 x86_64' } - - s.test_spec 'Tests' do |test_spec| - test_spec.source_files = 'Tests/**/*' - test_spec.dependency 'OCMock','3.5' - end end diff --git a/packages/local_auth/example/ios/Podfile b/packages/local_auth/example/ios/Podfile index 65497359e0a6..ef20d8e3c010 100644 --- a/packages/local_auth/example/ios/Podfile +++ b/packages/local_auth/example/ios/Podfile @@ -30,7 +30,7 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'XCTests' do + target 'RunnerTests' do inherit! :search_paths pod 'OCMock', '3.5' diff --git a/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj b/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj index 708c643bdf28..9c9597678546 100644 --- a/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - D6C28B8B9E1BDEC22D03304F /* libPods-XCTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4EB178B442E18480B8054307 /* libPods-XCTests.a */; }; + B726772E092FC537C9618264 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 719FE2C7EAF8D9A045E09C29 /* libPods-RunnerTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,18 +45,17 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 3398D2CD26163948005A052F /* XCTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XCTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3398D2CD26163948005A052F /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3398D2D126163948005A052F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3398D2DC261649CD005A052F /* liblocal_auth.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = liblocal_auth.a; sourceTree = BUILT_PRODUCTS_DIR; }; 3398D2DF26164A03005A052F /* liblocal_auth.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = liblocal_auth.a; sourceTree = BUILT_PRODUCTS_DIR; }; - 3398D2E326164AD8005A052F /* FLTLocalAuthPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FLTLocalAuthPluginTests.m; path = ../../../ios/Tests/FLTLocalAuthPluginTests.m; sourceTree = ""; }; + 3398D2E326164AD8005A052F /* FLTLocalAuthPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTLocalAuthPluginTests.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 4EB178B442E18480B8054307 /* libPods-XCTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-XCTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 658CDD04B21E4EA92F8EF229 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 719FE2C7EAF8D9A045E09C29 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; - 81D8AFFB31AECDACBC5B11F8 /* Pods-XCTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-XCTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-XCTests/Pods-XCTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -65,9 +64,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 99302E79EC77497F2F274D12 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9D274A3F79473B1549B2BBD5 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; EB36DF6C3F25E00DF4175422 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - F6BEBFD3433B1712765D62F7 /* Pods-XCTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-XCTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-XCTests/Pods-XCTests.release.xcconfig"; sourceTree = ""; }; + FEA527BB0A821430FEAA1566 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -75,7 +75,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D6C28B8B9E1BDEC22D03304F /* libPods-XCTests.a in Frameworks */, + B726772E092FC537C9618264 /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,13 +90,13 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 3398D2CE26163948005A052F /* XCTests */ = { + 33BF11D226680B2E002967F3 /* RunnerTests */ = { isa = PBXGroup; children = ( 3398D2E326164AD8005A052F /* FLTLocalAuthPluginTests.m */, 3398D2D126163948005A052F /* Info.plist */, ); - path = XCTests; + path = RunnerTests; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { @@ -113,9 +113,9 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 33BF11D226680B2E002967F3 /* RunnerTests */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - 3398D2CE26163948005A052F /* XCTests */, 97C146EF1CF9000F007C117D /* Products */, F8CC53B854B121315C7319D2 /* Pods */, E2D5FA899A019BD3E0DB0917 /* Frameworks */, @@ -126,7 +126,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 3398D2CD26163948005A052F /* XCTests.xctest */, + 3398D2CD26163948005A052F /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -161,7 +161,7 @@ 3398D2DF26164A03005A052F /* liblocal_auth.a */, 3398D2DC261649CD005A052F /* liblocal_auth.a */, 9D274A3F79473B1549B2BBD5 /* libPods-Runner.a */, - 4EB178B442E18480B8054307 /* libPods-XCTests.a */, + 719FE2C7EAF8D9A045E09C29 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -171,8 +171,8 @@ children = ( EB36DF6C3F25E00DF4175422 /* Pods-Runner.debug.xcconfig */, 658CDD04B21E4EA92F8EF229 /* Pods-Runner.release.xcconfig */, - 81D8AFFB31AECDACBC5B11F8 /* Pods-XCTests.debug.xcconfig */, - F6BEBFD3433B1712765D62F7 /* Pods-XCTests.release.xcconfig */, + 99302E79EC77497F2F274D12 /* Pods-RunnerTests.debug.xcconfig */, + FEA527BB0A821430FEAA1566 /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -180,9 +180,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 3398D2CC26163948005A052F /* XCTests */ = { + 3398D2CC26163948005A052F /* RunnerTests */ = { isa = PBXNativeTarget; - buildConfigurationList = 3398D2D426163948005A052F /* Build configuration list for PBXNativeTarget "XCTests" */; + buildConfigurationList = 3398D2D426163948005A052F /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( B5AF6C7A6759E6F38749E537 /* [CP] Check Pods Manifest.lock */, 3398D2C926163948005A052F /* Sources */, @@ -194,9 +194,9 @@ dependencies = ( 3398D2D326163948005A052F /* PBXTargetDependency */, ); - name = XCTests; - productName = XCTests; - productReference = 3398D2CD26163948005A052F /* XCTests.xctest */; + name = RunnerTests; + productName = RunnerTests; + productReference = 3398D2CD26163948005A052F /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { @@ -253,7 +253,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 3398D2CC26163948005A052F /* XCTests */, + 3398D2CC26163948005A052F /* RunnerTests */, ); }; /* End PBXProject section */ @@ -341,7 +341,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-XCTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -401,7 +401,7 @@ /* Begin XCBuildConfiguration section */ 3398D2D526163948005A052F /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 81D8AFFB31AECDACBC5B11F8 /* Pods-XCTests.debug.xcconfig */; + baseConfigurationReference = 99302E79EC77497F2F274D12 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -411,12 +411,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = XCTests/Info.plist; + INFOPLIST_FILE = RunnerTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.google.XCTests; + PRODUCT_BUNDLE_IDENTIFIER = com.google.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; @@ -425,7 +425,7 @@ }; 3398D2D626163948005A052F /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F6BEBFD3433B1712765D62F7 /* Pods-XCTests.release.xcconfig */; + baseConfigurationReference = FEA527BB0A821430FEAA1566 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -435,11 +435,11 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = XCTests/Info.plist; + INFOPLIST_FILE = RunnerTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.google.XCTests; + PRODUCT_BUNDLE_IDENTIFIER = com.google.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; @@ -597,7 +597,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 3398D2D426163948005A052F /* Build configuration list for PBXNativeTarget "XCTests" */ = { + 3398D2D426163948005A052F /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 3398D2D526163948005A052F /* Debug */, diff --git a/packages/local_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/local_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5b12c3ad032e..58a5d07a15c8 100644 --- a/packages/local_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/local_auth/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -42,8 +42,8 @@ diff --git a/packages/local_auth/ios/Tests/FLTLocalAuthPluginTests.m b/packages/local_auth/example/ios/RunnerTests/FLTLocalAuthPluginTests.m similarity index 100% rename from packages/local_auth/ios/Tests/FLTLocalAuthPluginTests.m rename to packages/local_auth/example/ios/RunnerTests/FLTLocalAuthPluginTests.m diff --git a/packages/local_auth/example/ios/RunnerTests/Info.plist b/packages/local_auth/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/local_auth/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj index e65843b137b0..5a45c7f4bc96 100644 --- a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -8,9 +8,9 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */; }; + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 686B4BF92548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */; }; - 68BDCAF623C3F97800D9C032 /* FLTWebViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -57,10 +57,10 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 27CC950C9005575711528C12 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FLTWKNavigationDelegateTests.m; path = ../../../ios/Tests/FLTWKNavigationDelegateTests.m; sourceTree = ""; }; + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWKNavigationDelegateTests.m; sourceTree = ""; }; 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 68BDCAED23C3F7CB00D9C032 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FLTWebViewTests.m; path = ../../../ios/Tests/FLTWebViewTests.m; sourceTree = ""; }; + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewTests.m; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -413,8 +413,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 68BDCAF623C3F97800D9C032 /* FLTWebViewTests.m in Sources */, - 686B4BF92548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m in Sources */, + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */, + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -479,8 +479,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.google.webview-flutter-exampleTests"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -494,8 +493,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.google.webview-flutter-exampleTests"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; diff --git a/packages/webview_flutter/ios/Tests/FLTWKNavigationDelegateTests.m b/packages/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m similarity index 100% rename from packages/webview_flutter/ios/Tests/FLTWKNavigationDelegateTests.m rename to packages/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m diff --git a/packages/webview_flutter/ios/Tests/FLTWebViewTests.m b/packages/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m similarity index 100% rename from packages/webview_flutter/ios/Tests/FLTWebViewTests.m rename to packages/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m diff --git a/packages/webview_flutter/ios/webview_flutter.podspec b/packages/webview_flutter/ios/webview_flutter.podspec index 066dfaacbfb9..1602f1c43daf 100644 --- a/packages/webview_flutter/ios/webview_flutter.podspec +++ b/packages/webview_flutter/ios/webview_flutter.podspec @@ -20,9 +20,4 @@ Downloaded by pub (not CocoaPods). s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - - s.test_spec 'Tests' do |test_spec| - test_spec.source_files = 'Tests/**/*' - test_spec.dependency 'OCMock','3.5' - end end From d37fa07f6f66a4e19d494b36364189c3c6a278aa Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Thu, 3 Jun 2021 22:44:04 +0300 Subject: [PATCH 014/364] [in_app_purchase] Fix Restore previous purchases link (#3988) --- packages/in_app_purchase/in_app_purchase/CHANGELOG.md | 4 ++++ packages/in_app_purchase/in_app_purchase/README.md | 2 +- packages/in_app_purchase/in_app_purchase/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 01c66d405960..e35e30a93983 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.4 + +* Fix `Restoring previous purchases` link in the README.md. + ## 1.0.3 * Added a "Restore purchases" button to conform to Apple's StoreKit guidelines on [restoring products](https://developer.apple.com/documentation/storekit/in-app_purchase/restoring_purchased_products?language=objc); diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index 5376834d4d1c..5e92f2d4a7f8 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -58,7 +58,7 @@ This section has examples of code for the following tasks: * [Listening to purchase updates](#listening-to-purchase-updates) * [Connecting to the underlying store](#connecting-to-the-underlying-store) * [Loading products for sale](#loading-products-for-sale) -* [Loading previous purchases](#loading-previous-purchases) +* [Restoring previous purchases](#restoring-previous-purchases) * [Making a purchase](#making-a-purchase) * [Completing a purchase](#completing-a-purchase) * [Upgrading or downgrading an existing in-app subscription](#upgrading-or-downgrading-an-existing-in-app-subscription) diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index 303986c58899..be7a100be774 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.3 +version: 1.0.4 environment: sdk: ">=2.12.0 <3.0.0" From 6340c96fe57a19b4f1fc3c6ef1d5f7ce12defc4c Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 3 Jun 2021 20:39:05 -0700 Subject: [PATCH 015/364] [shared_preferences] Add iOS unit tests (#4011) --- .../shared_preferences/CHANGELOG.md | 4 + .../shared_preferences/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 167 +++++++++++++++--- .../xcshareddata/xcschemes/Runner.xcscheme | 10 ++ .../contents.xcworkspacedata | 3 + .../example/ios/RunnerTests/Info.plist | 22 +++ .../ios/RunnerTests/SharedPreferencesTests.m | 18 ++ 7 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 packages/shared_preferences/shared_preferences/example/ios/RunnerTests/Info.plist create mode 100644 packages/shared_preferences/shared_preferences/example/ios/RunnerTests/SharedPreferencesTests.m diff --git a/packages/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index 97e25eeecf0d..3476f4eff3f0 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add iOS unit test target. + ## 2.0.6 * Migrate maven repository from jcenter to mavenCentral. diff --git a/packages/shared_preferences/shared_preferences/example/ios/Podfile b/packages/shared_preferences/shared_preferences/example/ios/Podfile index f7d6a5e68c3a..3924e59aa0f9 100644 --- a/packages/shared_preferences/shared_preferences/example/ios/Podfile +++ b/packages/shared_preferences/shared_preferences/example/ios/Podfile @@ -29,6 +29,9 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj index e26da81f1166..3c0b1b7537fc 100644 --- a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,18 +9,26 @@ /* Begin PBXBuildFile section */ 2D92224B1EC342E7007564B0 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 2D92224A1EC342E7007564B0 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4E8BDD90E81668641A750C18 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 556D8EFC85341B7D1FDF536D /* libPods-RunnerTests.a */; }; 66F8BCECCEFF62F4071D2DFC /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 12E03CD14DABAA3AD3923183 /* libPods-Runner.a */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F76AC2092669B6AE0040C8BC /* SharedPreferencesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F76AC2082669B6AE0040C8BC /* SharedPreferencesTests.m */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F76AC20B2669B6AE0040C8BC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -28,8 +36,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -42,20 +48,24 @@ 2D9222491EC342E7007564B0 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 2D92224A1EC342E7007564B0 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; + 556D8EFC85341B7D1FDF536D /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 942E815CEF30E101E045B849 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A2FC4F1DC78D7C01312F877F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + D896CE48B6CC2EB7D42CB6B6 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F76AC2062669B6AE0040C8BC /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F76AC2082669B6AE0040C8BC /* SharedPreferencesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SharedPreferencesTests.m; sourceTree = ""; }; + F76AC20A2669B6AE0040C8BC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,12 +73,18 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, 66F8BCECCEFF62F4071D2DFC /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC2032669B6AE0040C8BC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4E8BDD90E81668641A750C18 /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -77,6 +93,8 @@ children = ( 942E815CEF30E101E045B849 /* Pods-Runner.debug.xcconfig */, 081A3238A89B77A99B096D83 /* Pods-Runner.release.xcconfig */, + A2FC4F1DC78D7C01312F877F /* Pods-RunnerTests.debug.xcconfig */, + D896CE48B6CC2EB7D42CB6B6 /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -84,9 +102,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -99,6 +115,7 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + F76AC2072669B6AE0040C8BC /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, 840012C8B5EDBCF56B0E4AC1 /* Pods */, CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, @@ -109,6 +126,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + F76AC2062669B6AE0040C8BC /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -141,10 +159,20 @@ isa = PBXGroup; children = ( 12E03CD14DABAA3AD3923183 /* libPods-Runner.a */, + 556D8EFC85341B7D1FDF536D /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; }; + F76AC2072669B6AE0040C8BC /* RunnerTests */ = { + isa = PBXGroup; + children = ( + F76AC2082669B6AE0040C8BC /* SharedPreferencesTests.m */, + F76AC20A2669B6AE0040C8BC /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -158,7 +186,6 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, - 95BB15E9E1769C0D146AA592 /* [CP] Embed Pods Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); buildRules = ( @@ -170,6 +197,25 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F76AC2052669B6AE0040C8BC /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F76AC20F2669B6AE0040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + DB9B98025BDEFED85B1B62A7 /* [CP] Check Pods Manifest.lock */, + F76AC2022669B6AE0040C8BC /* Sources */, + F76AC2032669B6AE0040C8BC /* Frameworks */, + F76AC2042669B6AE0040C8BC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F76AC20C2669B6AE0040C8BC /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = F76AC2062669B6AE0040C8BC /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -182,6 +228,11 @@ 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; }; + F76AC2052669B6AE0040C8BC = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -198,6 +249,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + F76AC2052669B6AE0040C8BC /* RunnerTests */, ); }; /* End PBXProject section */ @@ -214,6 +266,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC2042669B6AE0040C8BC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -229,49 +288,56 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 95BB15E9E1769C0D146AA592 /* [CP] Embed Pods Frameworks */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; + name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + AB1344B0443C71CD721E1BB7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); - name = "Run Script"; + name = "[CP] Check Pods Manifest.lock"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - AB1344B0443C71CD721E1BB7 /* [CP] Check Pods Manifest.lock */ = { + DB9B98025BDEFED85B1B62A7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( "${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -291,8 +357,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F76AC2022669B6AE0040C8BC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F76AC2092669B6AE0040C8BC /* SharedPreferencesTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + F76AC20C2669B6AE0040C8BC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F76AC20B2669B6AE0040C8BC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -315,7 +397,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +453,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -463,6 +543,34 @@ }; name = Release; }; + F76AC20D2669B6AE0040C8BC /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A2FC4F1DC78D7C01312F877F /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + F76AC20E2669B6AE0040C8BC /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D896CE48B6CC2EB7D42CB6B6 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -484,6 +592,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F76AC20F2669B6AE0040C8BC /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F76AC20D2669B6AE0040C8BC /* Debug */, + F76AC20E2669B6AE0040C8BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3bb3697ef41c..5e29b432c48c 100644 --- a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -37,6 +37,16 @@ + + + + + + diff --git a/packages/shared_preferences/shared_preferences/example/ios/RunnerTests/Info.plist b/packages/shared_preferences/shared_preferences/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/shared_preferences/shared_preferences/example/ios/RunnerTests/SharedPreferencesTests.m b/packages/shared_preferences/shared_preferences/example/ios/RunnerTests/SharedPreferencesTests.m new file mode 100644 index 000000000000..08116fc38ee7 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/example/ios/RunnerTests/SharedPreferencesTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 shared_preferences; +@import XCTest; + +@interface SharedPreferencesTests : XCTestCase +@end + +@implementation SharedPreferencesTests + +- (void)testPlugin { + FLTSharedPreferencesPlugin* plugin = [[FLTSharedPreferencesPlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end From af2407514b0bcee1777f9e17ba94c23781b7c19f Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 3 Jun 2021 23:52:07 -0700 Subject: [PATCH 016/364] Do not trigger flat device orientation on iOS (#4010) --- packages/camera/camera/CHANGELOG.md | 4 ++ .../ios/Runner.xcodeproj/project.pbxproj | 11 ++- .../ios/RunnerTests/CameraOrientationTests.m | 67 +++++++++++++++++++ .../ios/RunnerTests/CameraPluginTests.m | 20 ------ .../camera/camera/ios/Classes/CameraPlugin.m | 5 ++ packages/camera/camera/pubspec.yaml | 2 +- 6 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m delete mode 100644 packages/camera/camera/example/ios/RunnerTests/CameraPluginTests.m diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 193feecbf920..2cab8e123ae6 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1+3 + +* Do not change camera orientation when iOS device is flat. + ## 0.8.1+2 * Fix iOS crash when selecting an unsupported FocusMode. diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index 1873cfb794cd..db93d888a300 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,7 +9,7 @@ /* Begin PBXBuildFile section */ 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB766A2665316900CE5A93 /* CameraFocusTests.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 334733EA2668111C00DCC49E /* CameraPluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */; }; + 334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; @@ -47,7 +47,7 @@ 03BB76682665316900CE5A93 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 03BB766A2665316900CE5A93 /* CameraFocusTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraFocusTests.m; sourceTree = ""; }; 03BB766C2665316900CE5A93 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraPluginTests.m; sourceTree = ""; }; + 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraOrientationTests.m; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -93,8 +93,8 @@ 03BB76692665316900CE5A93 /* RunnerTests */ = { isa = PBXGroup; children = ( - 03BB767226653ABE00CE5A93 /* CameraPluginTests.m */, 03BB766A2665316900CE5A93 /* CameraFocusTests.m */, + 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */, 03BB766C2665316900CE5A93 /* Info.plist */, ); path = RunnerTests; @@ -235,7 +235,6 @@ }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = 7624MWN53C; }; }; }; @@ -360,7 +359,7 @@ buildActionMask = 2147483647; files = ( 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, - 334733EA2668111C00DCC49E /* CameraPluginTests.m in Sources */, + 334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -564,7 +563,6 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - DEVELOPMENT_TEAM = 7624MWN53C; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -586,7 +584,6 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - DEVELOPMENT_TEAM = 7624MWN53C; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m new file mode 100644 index 000000000000..6c29ef7b2866 --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m @@ -0,0 +1,67 @@ +// Copyright 2013 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 camera; +@import XCTest; + +#import + +@interface CameraOrientationTests : XCTestCase +@property(strong, nonatomic) id mockRegistrar; +@property(strong, nonatomic) id mockMessenger; +@end + +@implementation CameraOrientationTests + +- (void)setUp { + [super setUp]; + self.mockRegistrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); + self.mockMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); + OCMStub([self.mockRegistrar messenger]).andReturn(self.mockMessenger); +} + +- (void)testOrientationNotifications { + id mockMessenger = self.mockMessenger; + [mockMessenger setExpectationOrderMatters:YES]; + XCUIDevice.sharedDevice.orientation = UIDeviceOrientationPortrait; + + [CameraPlugin registerWithRegistrar:self.mockRegistrar]; + + [self rotate:UIDeviceOrientationPortraitUpsideDown expectedChannelOrientation:@"portraitDown"]; + [self rotate:UIDeviceOrientationPortrait expectedChannelOrientation:@"portraitUp"]; + [self rotate:UIDeviceOrientationLandscapeRight expectedChannelOrientation:@"landscapeLeft"]; + [self rotate:UIDeviceOrientationLandscapeLeft expectedChannelOrientation:@"landscapeRight"]; + + OCMReject([mockMessenger sendOnChannel:[OCMArg any] message:[OCMArg any]]); + // No notification when orientation doesn't change. + XCUIDevice.sharedDevice.orientation = UIDeviceOrientationLandscapeLeft; + // No notification when flat. + XCUIDevice.sharedDevice.orientation = UIDeviceOrientationFaceUp; + // No notification when facedown. + XCUIDevice.sharedDevice.orientation = UIDeviceOrientationFaceDown; + + OCMVerifyAll(mockMessenger); +} + +- (void)rotate:(UIDeviceOrientation)deviceOrientation + expectedChannelOrientation:(NSString*)channelOrientation { + id mockMessenger = self.mockMessenger; + XCTestExpectation* orientationExpectation = [self expectationWithDescription:channelOrientation]; + + OCMExpect([mockMessenger + sendOnChannel:[OCMArg any] + message:[OCMArg checkWithBlock:^BOOL(NSData* data) { + NSObject* codec = [FlutterStandardMethodCodec sharedInstance]; + FlutterMethodCall* methodCall = [codec decodeMethodCall:data]; + [orientationExpectation fulfill]; + return + [methodCall.method isEqualToString:@"orientation_changed"] && + [methodCall.arguments isEqualToDictionary:@{@"orientation" : channelOrientation}]; + }]]); + + XCUIDevice.sharedDevice.orientation = deviceOrientation; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraPluginTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraPluginTests.m deleted file mode 100644 index 25496d539dc8..000000000000 --- a/packages/camera/camera/example/ios/RunnerTests/CameraPluginTests.m +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2013 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 camera; -@import XCTest; - -@interface CameraPluginTests : XCTestCase -@end - -@implementation CameraPluginTests - -- (void)testModuleImport { - // This test will fail to compile if the module cannot be imported. - // Make sure this plugin supports modules. See https://github.com/flutter/flutter/issues/41007. - // If not already present, add this line to the podspec: - // s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } -} - -@end diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index 1e818abda0ac..ebd5366ba78d 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -1292,6 +1292,11 @@ - (void)orientationChanged:(NSNotification *)note { UIDevice *device = note.object; UIDeviceOrientation orientation = device.orientation; + if (orientation == UIDeviceOrientationFaceUp || orientation == UIDeviceOrientationFaceDown) { + // Do not change when oriented flat. + return; + } + if (_camera) { [_camera setDeviceOrientation:orientation]; } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index de286835a0ed..a7df9e0d51be 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.8.1+2 +version: 0.8.1+3 environment: sdk: ">=2.12.0 <3.0.0" From 7f21f86afeb6d44f4248889a1c07c9985b9c3c89 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 4 Jun 2021 10:39:03 +0200 Subject: [PATCH 017/364] [in_app_purchase] Explanation for casting details to implementations (#4008) --- .../in_app_purchase/CHANGELOG.md | 4 ++ .../in_app_purchase/in_app_purchase/README.md | 68 +++++++++++++++++++ .../in_app_purchase/pubspec.yaml | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index e35e30a93983..d41c0d0d2aee 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.5 + +* Add explanation for casting `ProductDetails` and `PurchaseDetails` to platform specific implementations in the readme. + ## 1.0.4 * Fix `Restoring previous purchases` link in the README.md. diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index 5e92f2d4a7f8..ad28cfacb695 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -62,6 +62,7 @@ This section has examples of code for the following tasks: * [Making a purchase](#making-a-purchase) * [Completing a purchase](#completing-a-purchase) * [Upgrading or downgrading an existing in-app subscription](#upgrading-or-downgrading-an-existing-in-app-subscription) +* [Accessing platform specific product or purchase properties](#accessing-platform-specific-product-or-purchase-properties) * [Presenting a code redemption sheet (iOS 14)](#presenting-a-code-redemption-sheet-ios-14) ### Initializing the plugin @@ -245,6 +246,73 @@ InAppPurchase.instance .buyNonConsumable(purchaseParam: purchaseParam); ``` +### Accessing platform specific product or purchase properties + +The function `_inAppPurchase.queryProductDetails(productIds);` provides a `ProductDetailsResponse` with a +list of purchasable products of type `List`. This `ProductDetails` class is a platform independent class +containing properties only available on all endorsed platforms. However, in some cases it is necessary to access platform specific properties. The `ProductDetails` instance is of subtype `GooglePlayProductDetails` +when the platform is Android and `AppStoreProductDetails` on iOS. Accessing the skuDetails (on Android) or the skProduct (on iOS) provides all the information that is available in the original platform objects. + +This is an example on how to get the `introductoryPricePeriod` on Android: +```dart +//import for GooglePlayProductDetails +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +//import for SkuDetailsWrapper +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; + +if (productDetails is GooglePlayProductDetails) { + SkuDetailsWrapper skuDetails = (productDetails as GooglePlayProductDetails).skuDetails; + print(skuDetails.introductoryPricePeriod); +} +``` + +And this is the way to get the subscriptionGroupIdentifier of a subscription on iOS: +```dart +//import for AppStoreProductDetails +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +//import for SKProductWrapper +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +if (productDetails is AppStoreProductDetails) { + SKProductWrapper skProduct = (productDetails as AppStoreProductDetails).skProduct; + print(skProduct.subscriptionGroupIdentifier); +} +``` + +The `purchaseStream` provides objects of type `PurchaseDetails`. PurchaseDetails' provides all +information that is available on all endorsed platforms, such as purchaseID and transactionDate. In addition, it is +possible to access the platform specific properties. The `PurchaseDetails` object is of subtype `GooglePlayPurchaseDetails` +when the platform is Android and `AppStorePurchaseDetails` on iOS. Accessing the billingClientPurchase, resp. +skPaymentTransaction provides all the information that is available in the original platform objects. + +This is an example on how to get the `originalJson` on Android: +```dart +//import for GooglePlayPurchaseDetails +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +//import for PurchaseWrapper +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; + +if (purchaseDetails is GooglePlayPurchaseDetails) { + PurchaseWrapper billingClientPurchase = (purchaseDetails as GooglePlayPurchaseDetails).billingClientPurchase; + print(billingClientPurchase.originalJson); +} +``` + +How to get the `transactionState` of a purchase in iOS: +```dart +//import for AppStorePurchaseDetails +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +//import for SKProductWrapper +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +if (purchaseDetails is AppStorePurchaseDetails) { + SKPaymentTransactionWrapper skProduct = (purchaseDetails as AppStorePurchaseDetails).skPaymentTransaction; + print(skProduct.transactionState); +} +``` + +Please note that it is required to import `in_app_purchase_android` and/or `in_app_purchase_ios`. + ### Presenting a code redemption sheet (iOS 14) The following code brings up a sheet that enables the user to redeem offer diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index be7a100be774..aa2e8fcdee6b 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.4 +version: 1.0.5 environment: sdk: ">=2.12.0 <3.0.0" From 5eafa665e04bde5685327cec21aa54bd90734596 Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Fri, 4 Jun 2021 10:24:05 -0700 Subject: [PATCH 018/364] Remove "unnecessary" imports. (#4012) --- script/tool/test/publish_plugin_command_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 357e72efa004..3618cc38bc81 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -14,7 +14,6 @@ import 'package:file/local.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; import 'package:git/git.dart'; -import 'package:matcher/matcher.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; From 8ac628625c0eb46cf4dc35fe0fd39b992a27a3ed Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 4 Jun 2021 16:48:31 -0700 Subject: [PATCH 019/364] Enable linting of macOS podspecs (#4015) - Removes the .cirrus.yml workaround that removed macOS podspecs before linting - Deletes the dummy macOS podspecs that are no longer needed due to `flutter` fixes - Adds --use-modular-headers to the lint command to reflect what Flutter Podfiles do - Fix the actual issues in the podspecs --- .cirrus.yml | 2 -- .../connectivity/macos/connectivity.podspec | 22 ------------------- .../connectivity_macos/CHANGELOG.md | 4 ++++ .../macos/connectivity_macos.podspec | 3 ++- .../package_info/macos/package_info.podspec | 12 +++++----- .../path_provider/macos/path_provider.podspec | 22 ------------------- .../path_provider_macos/CHANGELOG.md | 4 ++++ .../macos/path_provider_macos.podspec | 1 + .../macos/shared_preferences.podspec | 22 ------------------- .../url_launcher/macos/url_launcher.podspec | 22 ------------------- .../tool/lib/src/lint_podspecs_command.dart | 1 + .../tool/test/lint_podspecs_command_test.dart | 4 ++++ 12 files changed, 22 insertions(+), 97 deletions(-) delete mode 100644 packages/connectivity/connectivity/macos/connectivity.podspec delete mode 100644 packages/path_provider/path_provider/macos/path_provider.podspec delete mode 100644 packages/shared_preferences/shared_preferences/macos/shared_preferences.podspec delete mode 100644 packages/url_launcher/url_launcher/macos/url_launcher.podspec diff --git a/.cirrus.yml b/.cirrus.yml index 129b6cb84479..95b739eefbb0 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -184,8 +184,6 @@ task: ### iOS+macOS tasks *** - name: lint_darwin_plugins script: - # TODO(jmagman): Lint macOS podspecs but skip any that fail library validation. - - find . -name "*.podspec" | xargs grep -l "osx" | xargs rm - ./script/tool_runner.sh podspecs ### iOS tasks ### - name: build_all_plugins_ipa diff --git a/packages/connectivity/connectivity/macos/connectivity.podspec b/packages/connectivity/connectivity/macos/connectivity.podspec deleted file mode 100644 index ea544dfc15de..000000000000 --- a/packages/connectivity/connectivity/macos/connectivity.podspec +++ /dev/null @@ -1,22 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# -Pod::Spec.new do |s| - s.name = 'connectivity' - s.version = '0.0.1' - s.summary = 'No-op implementation of the macos connectivity to avoid build issues on macos' - s.description = <<-DESC - No-op implementation of the connectivity plugin to avoid build issues on macos. - https://github.com/flutter/flutter/issues/46618 - DESC - s.homepage = 'https://github.com/flutter/plugins/tree/master/packages/connectivity' - s.license = { :file => '../LICENSE' } - s.author = { 'Flutter Team' => 'flutter-dev@googlegroups.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - - s.platform = :osx - s.osx.deployment_target = '10.11' -end - diff --git a/packages/connectivity/connectivity_macos/CHANGELOG.md b/packages/connectivity/connectivity_macos/CHANGELOG.md index 80d4d87319b7..c7bc5b4cf469 100644 --- a/packages/connectivity/connectivity_macos/CHANGELOG.md +++ b/packages/connectivity/connectivity_macos/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add Swift language version to podspec. + ## 0.2.1+1 * Ignore Reachability pointer to int cast warning. diff --git a/packages/connectivity/connectivity_macos/macos/connectivity_macos.podspec b/packages/connectivity/connectivity_macos/macos/connectivity_macos.podspec index 57836f900b5c..51629084a23d 100644 --- a/packages/connectivity/connectivity_macos/macos/connectivity_macos.podspec +++ b/packages/connectivity/connectivity_macos/macos/connectivity_macos.podspec @@ -18,4 +18,5 @@ Pod::Spec.new do |s| s.platform = :osx s.osx.deployment_target = '10.11' -end \ No newline at end of file + s.swift_version = '5.0' +end diff --git a/packages/package_info/macos/package_info.podspec b/packages/package_info/macos/package_info.podspec index 3c342ec6b8c5..dbe5bd9a105b 100644 --- a/packages/package_info/macos/package_info.podspec +++ b/packages/package_info/macos/package_info.podspec @@ -4,14 +4,14 @@ Pod::Spec.new do |s| s.name = 'package_info' s.version = '0.0.1' - s.summary = 'A new flutter plugin project.' + s.summary = 'Flutter plugin for querying information about the application package.' s.description = <<-DESC -A new flutter plugin project. +Flutter plugin for querying information about the application package, based on bundle data. DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } + s.homepage = 'https://github.com/flutter/plugins/tree/master/packages/package_info' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/package_info' } s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'FlutterMacOS' diff --git a/packages/path_provider/path_provider/macos/path_provider.podspec b/packages/path_provider/path_provider/macos/path_provider.podspec deleted file mode 100644 index 9f3f01f2f858..000000000000 --- a/packages/path_provider/path_provider/macos/path_provider.podspec +++ /dev/null @@ -1,22 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# -Pod::Spec.new do |s| - s.name = 'path_provider' - s.version = '0.0.1' - s.summary = 'No-op implementation of the macos path_provider to avoid build issues on macos' - s.description = <<-DESC - No-op implementation of the path_provider plugin to avoid build issues on macos. - https://github.com/flutter/flutter/issues/46618 - DESC - s.homepage = 'https://github.com/flutter/plugins/tree/master/packages/path_provider' - s.license = { :file => '../LICENSE' } - s.author = { 'Flutter Team' => 'flutter-dev@googlegroups.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - - s.platform = :osx - s.osx.deployment_target = '10.11' -end - diff --git a/packages/path_provider/path_provider_macos/CHANGELOG.md b/packages/path_provider/path_provider_macos/CHANGELOG.md index 772ebcce8b8f..ce1b5517968f 100644 --- a/packages/path_provider/path_provider_macos/CHANGELOG.md +++ b/packages/path_provider/path_provider_macos/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add Swift language version to podspec. + ## 2.0.1 * Add `implements` to pubspec.yaml. diff --git a/packages/path_provider/path_provider_macos/macos/path_provider_macos.podspec b/packages/path_provider/path_provider_macos/macos/path_provider_macos.podspec index be47ddb18b16..66b5872c9ac9 100644 --- a/packages/path_provider/path_provider_macos/macos/path_provider_macos.podspec +++ b/packages/path_provider/path_provider_macos/macos/path_provider_macos.podspec @@ -17,5 +17,6 @@ Pod::Spec.new do |s| s.platform = :osx s.osx.deployment_target = '10.11' + s.swift_version = '5.0' end diff --git a/packages/shared_preferences/shared_preferences/macos/shared_preferences.podspec b/packages/shared_preferences/shared_preferences/macos/shared_preferences.podspec deleted file mode 100644 index 5eeb3df11b23..000000000000 --- a/packages/shared_preferences/shared_preferences/macos/shared_preferences.podspec +++ /dev/null @@ -1,22 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# -Pod::Spec.new do |s| - s.name = 'shared_preferences' - s.version = '0.0.1' - s.summary = 'No-op implementation of the macos shared_preferences to avoid build issues on macos' - s.description = <<-DESC - No-op implementation of the shared_preferences plugin to avoid build issues on macos. - https://github.com/flutter/flutter/issues/46618 - DESC - s.homepage = 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences' - s.license = { :file => '../LICENSE' } - s.author = { 'Flutter Team' => 'flutter-dev@googlegroups.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - - s.platform = :osx - s.osx.deployment_target = '10.11' -end - diff --git a/packages/url_launcher/url_launcher/macos/url_launcher.podspec b/packages/url_launcher/url_launcher/macos/url_launcher.podspec deleted file mode 100644 index 2ddd8ced06d1..000000000000 --- a/packages/url_launcher/url_launcher/macos/url_launcher.podspec +++ /dev/null @@ -1,22 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# -Pod::Spec.new do |s| - s.name = 'url_launcher' - s.version = '0.0.1' - s.summary = 'No-op implementation of the macos url_launcher to avoid build issues on macos' - s.description = <<-DESC - No-op implementation of the url_launcher plugin to avoid build issues on macos. - https://github.com/flutter/flutter/issues/46618 - DESC - s.homepage = 'https://github.com/flutter/plugins/tree/master/packages/url_launcher' - s.license = { :file => '../LICENSE' } - s.author = { 'Flutter Team' => 'flutter-dev@googlegroups.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - - s.platform = :osx - s.osx.deployment_target = '10.11' -end - diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 0bf4c69b24f4..5c66373ac6f6 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -132,6 +132,7 @@ class LintPodspecsCommand extends PluginCommand { podspecPath, '--configuration=Debug', // Release targets unsupported arm64 simulators. Use Debug to only build against targeted x86_64 simulator devices. '--skip-tests', + '--use-modular-headers', // Flutter sets use_modular_headers! in its templates. if (allowWarnings) '--allow-warnings', if (libraryLint) '--use-libraries' ]; diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 13bb1cc8d32f..fad750393a78 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -87,6 +87,7 @@ void main() { p.join(plugin1Dir.path, 'ios', 'plugin1.podspec'), '--configuration=Debug', '--skip-tests', + '--use-modular-headers', '--use-libraries' ], mockPackagesDir.path), @@ -98,6 +99,7 @@ void main() { p.join(plugin1Dir.path, 'ios', 'plugin1.podspec'), '--configuration=Debug', '--skip-tests', + '--use-modular-headers', ], mockPackagesDir.path), ]), @@ -147,6 +149,7 @@ void main() { p.join(plugin1Dir.path, 'plugin1.podspec'), '--configuration=Debug', '--skip-tests', + '--use-modular-headers', '--allow-warnings', '--use-libraries' ], @@ -159,6 +162,7 @@ void main() { p.join(plugin1Dir.path, 'plugin1.podspec'), '--configuration=Debug', '--skip-tests', + '--use-modular-headers', '--allow-warnings', ], mockPackagesDir.path), From 996a4a9fcbbe2bcc4a89878784d0299ca7d28603 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Sat, 5 Jun 2021 10:32:24 -0700 Subject: [PATCH 020/364] [flutter_plugin_tools] Simplify filesystem usage (#4014) - Replaces most explicit use of `fileSystem` with path construction using the `child*` utility methods - Removes explicit passing of a filesystem to the commands; we're already passing a `Directory` for the root where the tool operates, and we should never be using a different filesystem than that directory's filesystem, so passing it was both redundant, and a potential source of test bugs. --- script/tool/lib/src/analyze_command.dart | 5 +- .../tool/lib/src/build_examples_command.dart | 17 +++--- script/tool/lib/src/common.dart | 54 ++++++++----------- .../src/create_all_plugins_app_command.dart | 11 ++-- .../tool/lib/src/drive_examples_command.dart | 40 +++++++------- .../lib/src/firebase_test_lab_command.dart | 28 +++++----- script/tool/lib/src/format_command.dart | 8 +-- script/tool/lib/src/java_test_command.dart | 27 +++++----- .../tool/lib/src/license_check_command.dart | 5 +- .../tool/lib/src/lint_podspecs_command.dart | 5 +- script/tool/lib/src/list_command.dart | 3 +- script/tool/lib/src/main.dart | 39 +++++++------- .../tool/lib/src/publish_check_command.dart | 5 +- .../tool/lib/src/publish_plugin_command.dart | 25 ++++----- .../tool/lib/src/pubspec_check_command.dart | 6 +-- script/tool/lib/src/test_command.dart | 11 ++-- .../tool/lib/src/version_check_command.dart | 8 ++- script/tool/lib/src/xctest_command.dart | 7 ++- script/tool/test/analyze_command_test.dart | 5 +- .../test/build_examples_command_test.dart | 5 +- script/tool/test/common_test.dart | 16 ++---- .../create_all_plugins_app_command_test.dart | 1 - .../test/drive_examples_command_test.dart | 5 +- script/tool/test/firebase_test_lab_test.dart | 2 +- script/tool/test/java_test_command_test.dart | 5 +- .../tool/test/license_check_command_test.dart | 1 - .../tool/test/lint_podspecs_command_test.dart | 1 - script/tool/test/list_command_test.dart | 2 +- .../tool/test/publish_check_command_test.dart | 18 +++---- .../test/publish_plugin_command_test.dart | 5 +- .../tool/test/pubspec_check_command_test.dart | 5 +- script/tool/test/test_command_test.dart | 4 +- script/tool/test/version_check_test.dart | 24 +++------ script/tool/test/xctest_command_test.dart | 5 +- 34 files changed, 177 insertions(+), 231 deletions(-) diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 872645ec16cb..076c8f69885d 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -13,10 +13,9 @@ import 'common.dart'; class AnalyzeCommand extends PluginCommand { /// Creates a analysis command instance. AnalyzeCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addMultiOption(_customAnalysisFlag, help: 'Directories (comma separated) that are allowed to have their own analysis options.', diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 75bff3b25dca..82fb12e70d47 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -15,10 +15,9 @@ import 'common.dart'; class BuildExamplesCommand extends PluginCommand { /// Creates an instance of the build command. BuildExamplesCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addFlag(kLinux, defaultsTo: false); argParser.addFlag(kMacos, defaultsTo: false); argParser.addFlag(kWeb, defaultsTo: false); @@ -69,7 +68,7 @@ class BuildExamplesCommand extends PluginCommand { if (getBoolArg(kLinux)) { print('\nBUILDING Linux for $packageName'); - if (isLinuxPlugin(plugin, fileSystem)) { + if (isLinuxPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ @@ -89,7 +88,7 @@ class BuildExamplesCommand extends PluginCommand { if (getBoolArg(kMacos)) { print('\nBUILDING macOS for $packageName'); - if (isMacOsPlugin(plugin, fileSystem)) { + if (isMacOsPlugin(plugin)) { final int exitCode = await processRunner.runAndStream( flutterCommand, [ @@ -109,7 +108,7 @@ class BuildExamplesCommand extends PluginCommand { if (getBoolArg(kWeb)) { print('\nBUILDING web for $packageName'); - if (isWebPlugin(plugin, fileSystem)) { + if (isWebPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ @@ -129,7 +128,7 @@ class BuildExamplesCommand extends PluginCommand { if (getBoolArg(kWindows)) { print('\nBUILDING Windows for $packageName'); - if (isWindowsPlugin(plugin, fileSystem)) { + if (isWindowsPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ @@ -149,7 +148,7 @@ class BuildExamplesCommand extends PluginCommand { if (getBoolArg(kIpa)) { print('\nBUILDING IPA for $packageName'); - if (isIosPlugin(plugin, fileSystem)) { + if (isIosPlugin(plugin)) { final int exitCode = await processRunner.runAndStream( flutterCommand, [ @@ -170,7 +169,7 @@ class BuildExamplesCommand extends PluginCommand { if (getBoolArg(kApk)) { print('\nBUILDING APK for $packageName'); - if (isAndroidPlugin(plugin, fileSystem)) { + if (isAndroidPlugin(plugin)) { final int exitCode = await processRunner.runAndStream( flutterCommand, [ diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart index 8b16559d4586..f58290b5e07a 100644 --- a/script/tool/lib/src/common.dart +++ b/script/tool/lib/src/common.dart @@ -48,14 +48,13 @@ const String kApk = 'apk'; const String kEnableExperiment = 'enable-experiment'; /// Returns whether the given directory contains a Flutter package. -bool isFlutterPackage(FileSystemEntity entity, FileSystem fileSystem) { +bool isFlutterPackage(FileSystemEntity entity) { if (entity is! Directory) { return false; } try { - final File pubspecFile = - fileSystem.file(p.join(entity.path, 'pubspec.yaml')); + final File pubspecFile = entity.childFile('pubspec.yaml'); final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; final YamlMap? dependencies = pubspecYaml['dependencies'] as YamlMap?; @@ -78,8 +77,7 @@ bool isFlutterPackage(FileSystemEntity entity, FileSystem fileSystem) { /// plugin: /// platforms: /// [platform]: -bool pluginSupportsPlatform( - String platform, FileSystemEntity entity, FileSystem fileSystem) { +bool pluginSupportsPlatform(String platform, FileSystemEntity entity) { assert(platform == kIos || platform == kAndroid || platform == kWeb || @@ -91,8 +89,7 @@ bool pluginSupportsPlatform( } try { - final File pubspecFile = - fileSystem.file(p.join(entity.path, 'pubspec.yaml')); + final File pubspecFile = entity.childFile('pubspec.yaml'); final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; @@ -120,33 +117,33 @@ bool pluginSupportsPlatform( } /// Returns whether the given directory contains a Flutter Android plugin. -bool isAndroidPlugin(FileSystemEntity entity, FileSystem fileSystem) { - return pluginSupportsPlatform(kAndroid, entity, fileSystem); +bool isAndroidPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kAndroid, entity); } /// Returns whether the given directory contains a Flutter iOS plugin. -bool isIosPlugin(FileSystemEntity entity, FileSystem fileSystem) { - return pluginSupportsPlatform(kIos, entity, fileSystem); +bool isIosPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kIos, entity); } /// Returns whether the given directory contains a Flutter web plugin. -bool isWebPlugin(FileSystemEntity entity, FileSystem fileSystem) { - return pluginSupportsPlatform(kWeb, entity, fileSystem); +bool isWebPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kWeb, entity); } /// Returns whether the given directory contains a Flutter Windows plugin. -bool isWindowsPlugin(FileSystemEntity entity, FileSystem fileSystem) { - return pluginSupportsPlatform(kWindows, entity, fileSystem); +bool isWindowsPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kWindows, entity); } /// Returns whether the given directory contains a Flutter macOS plugin. -bool isMacOsPlugin(FileSystemEntity entity, FileSystem fileSystem) { - return pluginSupportsPlatform(kMacos, entity, fileSystem); +bool isMacOsPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kMacos, entity); } /// Returns whether the given directory contains a Flutter linux plugin. -bool isLinuxPlugin(FileSystemEntity entity, FileSystem fileSystem) { - return pluginSupportsPlatform(kLinux, entity, fileSystem); +bool isLinuxPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kLinux, entity); } /// Throws a [ToolExit] with `exitCode` and log the `errorMessage` in red. @@ -169,8 +166,7 @@ class ToolExit extends Error { abstract class PluginCommand extends Command { /// Creates a command to operate on [packagesDir] with the given environment. PluginCommand( - this.packagesDir, - this.fileSystem, { + this.packagesDir, { this.processRunner = const ProcessRunner(), this.gitDir, }) { @@ -223,11 +219,6 @@ abstract class PluginCommand extends Command { /// The directory containing the plugin packages. final Directory packagesDir; - /// The file system. - /// - /// This can be overridden for testing. - final FileSystem fileSystem; - /// The process runner. /// /// This can be overridden for testing. @@ -414,19 +405,17 @@ abstract class PluginCommand extends Command { /// Returns whether the specified entity is a directory containing a /// `pubspec.yaml` file. bool _isDartPackage(FileSystemEntity entity) { - return entity is Directory && - fileSystem.file(p.join(entity.path, 'pubspec.yaml')).existsSync(); + return entity is Directory && entity.childFile('pubspec.yaml').existsSync(); } /// Returns the example Dart packages contained in the specified plugin, or /// an empty List, if the plugin has no examples. Iterable getExamplesForPlugin(Directory plugin) { - final Directory exampleFolder = - fileSystem.directory(p.join(plugin.path, 'example')); + final Directory exampleFolder = plugin.childDirectory('example'); if (!exampleFolder.existsSync()) { return []; } - if (isFlutterPackage(exampleFolder, fileSystem)) { + if (isFlutterPackage(exampleFolder)) { return [exampleFolder]; } // Only look at the subdirectories of the example directory if the example @@ -434,8 +423,7 @@ abstract class PluginCommand extends Command { // example directory for other dart packages. return exampleFolder .listSync() - .where( - (FileSystemEntity entity) => isFlutterPackage(entity, fileSystem)) + .where((FileSystemEntity entity) => isFlutterPackage(entity)) .cast(); } diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index e02ebb70f50b..9de7f1b904a1 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -8,7 +8,6 @@ import 'dart:async'; import 'dart:io' as io; import 'package:file/file.dart'; -import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -18,11 +17,10 @@ import 'common.dart'; class CreateAllPluginsAppCommand extends PluginCommand { /// Creates an instance of the builder command. CreateAllPluginsAppCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { this.pluginsRoot, - }) : super(packagesDir, fileSystem) { - pluginsRoot ??= fileSystem.currentDirectory; + }) : super(packagesDir) { + pluginsRoot ??= packagesDir.fileSystem.currentDirectory; appDirectory = pluginsRoot.childDirectory('all_plugins'); } @@ -161,8 +159,7 @@ class CreateAllPluginsAppCommand extends PluginCommand { await for (final Directory package in getPlugins()) { final String pluginName = package.path.split('/').last; - final File pubspecFile = - fileSystem.file(p.join(package.path, 'pubspec.yaml')); + final File pubspecFile = package.childFile('pubspec.yaml'); final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); if (pubspec.publishTo != 'none') { diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 15f465f53591..4678a9de3f18 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -12,10 +12,9 @@ import 'common.dart'; class DriveExamplesCommand extends PluginCommand { /// Creates an instance of the drive command. DriveExamplesCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addFlag(kAndroid, help: 'Runs the Android implementation of the examples'); argParser.addFlag(kIos, @@ -67,7 +66,7 @@ class DriveExamplesCommand extends PluginCommand { continue; } print('\n==========\nChecking $pluginName...'); - if (!(await _pluginSupportedOnCurrentPlatform(plugin, fileSystem))) { + if (!(await _pluginSupportedOnCurrentPlatform(plugin))) { print('Not supported for the target platform; skipping.'); continue; } @@ -79,8 +78,7 @@ class DriveExamplesCommand extends PluginCommand { ++examplesFound; final String packageName = p.relative(example.path, from: packagesDir.path); - final Directory driverTests = - fileSystem.directory(p.join(example.path, 'test_driver')); + final Directory driverTests = example.childDirectory('test_driver'); if (!driverTests.existsSync()) { print('No driver tests found for $packageName'); continue; @@ -98,7 +96,7 @@ class DriveExamplesCommand extends PluginCommand { '.dart', ); String deviceTestPath = p.join('test', deviceTestName); - if (!fileSystem + if (!example.fileSystem .file(p.join(example.path, deviceTestPath)) .existsSync()) { // If the app isn't in test/ folder, look in test_driver/ instead. @@ -106,13 +104,13 @@ class DriveExamplesCommand extends PluginCommand { } final List targetPaths = []; - if (fileSystem + if (example.fileSystem .file(p.join(example.path, deviceTestPath)) .existsSync()) { targetPaths.add(deviceTestPath); } else { final Directory integrationTests = - fileSystem.directory(p.join(example.path, 'integration_test')); + example.childDirectory('integration_test'); if (await integrationTests.exists()) { await for (final FileSystemEntity integrationTest @@ -145,19 +143,19 @@ Tried searching for the following: driveArgs.add('--enable-experiment=$enableExperiment'); } - if (isLinux && isLinuxPlugin(plugin, fileSystem)) { + if (isLinux && isLinuxPlugin(plugin)) { driveArgs.addAll([ '-d', 'linux', ]); } - if (isMacos && isMacOsPlugin(plugin, fileSystem)) { + if (isMacos && isMacOsPlugin(plugin)) { driveArgs.addAll([ '-d', 'macos', ]); } - if (isWeb && isWebPlugin(plugin, fileSystem)) { + if (isWeb && isWebPlugin(plugin)) { driveArgs.addAll([ '-d', 'web-server', @@ -165,7 +163,7 @@ Tried searching for the following: '--browser-name=chrome', ]); } - if (isWindows && isWindowsPlugin(plugin, fileSystem)) { + if (isWindows && isWindowsPlugin(plugin)) { driveArgs.addAll([ '-d', 'windows', @@ -220,7 +218,7 @@ Tried searching for the following: } Future _pluginSupportedOnCurrentPlatform( - FileSystemEntity plugin, FileSystem fileSystem) async { + FileSystemEntity plugin) async { final bool isAndroid = getBoolArg(kAndroid); final bool isIOS = getBoolArg(kIos); final bool isLinux = getBoolArg(kLinux); @@ -228,27 +226,27 @@ Tried searching for the following: final bool isWeb = getBoolArg(kWeb); final bool isWindows = getBoolArg(kWindows); if (isAndroid) { - return isAndroidPlugin(plugin, fileSystem); + return isAndroidPlugin(plugin); } if (isIOS) { - return isIosPlugin(plugin, fileSystem); + return isIosPlugin(plugin); } if (isLinux) { - return isLinuxPlugin(plugin, fileSystem); + return isLinuxPlugin(plugin); } if (isMacos) { - return isMacOsPlugin(plugin, fileSystem); + return isMacOsPlugin(plugin); } if (isWeb) { - return isWebPlugin(plugin, fileSystem); + return isWebPlugin(plugin); } if (isWindows) { - return isWindowsPlugin(plugin, fileSystem); + return isWindowsPlugin(plugin); } // When we are here, no flags are specified. Only return true if the plugin // supports Android for legacy command support. // TODO(cyanglaz): Make Android flag also required like other platforms // (breaking change). https://github.com/flutter/flutter/issues/58285 - return isAndroidPlugin(plugin, fileSystem); + return isAndroidPlugin(plugin); } } diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 2d91deffa2c4..6db0d629e59f 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -17,12 +17,11 @@ import 'common.dart'; class FirebaseTestLabCommand extends PluginCommand { /// Creates an instance of the test runner command. FirebaseTestLabCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Print print = print, }) : _print = print, - super(packagesDir, fileSystem, processRunner: processRunner) { + super(packagesDir, processRunner: processRunner) { argParser.addOption( 'project', defaultsTo: 'flutter-infra', @@ -105,10 +104,13 @@ class FirebaseTestLabCommand extends PluginCommand { Future run() async { final Stream packagesWithTests = getPackages().where( (Directory d) => - isFlutterPackage(d, fileSystem) && - fileSystem - .directory(p.join( - d.path, 'example', 'android', 'app', 'src', 'androidTest')) + isFlutterPackage(d) && + d + .childDirectory('example') + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('androidTest') .existsSync()); final List failingPackages = []; @@ -118,23 +120,20 @@ class FirebaseTestLabCommand extends PluginCommand { await for (final Directory package in packagesWithTests) { // See https://github.com/flutter/flutter/issues/38983 - final Directory exampleDirectory = - fileSystem.directory(p.join(package.path, 'example')); + final Directory exampleDirectory = package.childDirectory('example'); final String packageName = p.relative(package.path, from: packagesDir.path); _print('\nRUNNING FIREBASE TEST LAB TESTS for $packageName'); final Directory androidDirectory = - fileSystem.directory(p.join(exampleDirectory.path, 'android')); + exampleDirectory.childDirectory('android'); final String enableExperiment = getStringArg(kEnableExperiment); final String encodedEnableExperiment = Uri.encodeComponent('--enable-experiment=$enableExperiment'); // Ensures that gradle wrapper exists - if (!fileSystem - .file(p.join(androidDirectory.path, _gradleWrapper)) - .existsSync()) { + if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { final int exitCode = await processRunner.runAndStream( 'flutter', [ @@ -181,8 +180,7 @@ class FirebaseTestLabCommand extends PluginCommand { final List testDirs = package.listSync().where(isTestDir).cast().toList(); - final Directory example = - fileSystem.directory(p.join(package.path, 'example')); + final Directory example = package.childDirectory('example'); testDirs.addAll( example.listSync().where(isTestDir).cast().toList()); for (final Directory testDir in testDirs) { diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index 51ab7957d883..1ef41f82bb2c 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -20,10 +20,9 @@ final Uri _googleFormatterUrl = Uri.https('github.com', class FormatCommand extends PluginCommand { /// Creates an instance of the format command. FormatCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addFlag('fail-on-change', hide: true); argParser.addOption('clang-format', defaultsTo: 'clang-format', @@ -144,7 +143,8 @@ class FormatCommand extends PluginCommand { final String javaFormatterPath = p.join( p.dirname(p.fromUri(io.Platform.script)), 'google-java-format-1.3-all-deps.jar'); - final File javaFormatterFile = fileSystem.file(javaFormatterPath); + final File javaFormatterFile = + packagesDir.fileSystem.file(javaFormatterPath); if (!javaFormatterFile.existsSync()) { print('Downloading Google Java Format...'); diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart index 5df97627cecd..d1366ea7636a 100644 --- a/script/tool/lib/src/java_test_command.dart +++ b/script/tool/lib/src/java_test_command.dart @@ -13,10 +13,9 @@ import 'common.dart'; class JavaTestCommand extends PluginCommand { /// Creates an instance of the test runner. JavaTestCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner); + }) : super(packagesDir, processRunner: processRunner); @override final String name = 'java-test'; @@ -32,12 +31,17 @@ class JavaTestCommand extends PluginCommand { Future run() async { final Stream examplesWithTests = getExamples().where( (Directory d) => - isFlutterPackage(d, fileSystem) && - (fileSystem - .directory(p.join(d.path, 'android', 'app', 'src', 'test')) + isFlutterPackage(d) && + (d + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('test') .existsSync() || - fileSystem - .directory(p.join(d.path, '..', 'android', 'src', 'test')) + d.parent + .childDirectory('android') + .childDirectory('src') + .childDirectory('test') .existsSync())); final List failingPackages = []; @@ -47,11 +51,8 @@ class JavaTestCommand extends PluginCommand { p.relative(example.path, from: packagesDir.path); print('\nRUNNING JAVA TESTS for $packageName'); - final Directory androidDirectory = - fileSystem.directory(p.join(example.path, 'android')); - if (!fileSystem - .file(p.join(androidDirectory.path, _gradleWrapper)) - .existsSync()) { + final Directory androidDirectory = example.childDirectory('android'); + if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { print('ERROR: Run "flutter build apk" on example app of $packageName' 'before executing tests.'); missingFlutterBuild.add(packageName); diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index a6528640b828..805c3ab9f900 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -98,11 +98,10 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class LicenseCheckCommand extends PluginCommand { /// Creates a new license check command for [packagesDir]. LicenseCheckCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { Print print = print, }) : _print = print, - super(packagesDir, fileSystem); + super(packagesDir); final Print _print; diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 5c66373ac6f6..72bb6af3f64a 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -20,14 +20,13 @@ import 'common.dart'; class LintPodspecsCommand extends PluginCommand { /// Creates an instance of the linter command. LintPodspecsCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), Print print = print, }) : _platform = platform, _print = print, - super(packagesDir, fileSystem, processRunner: processRunner) { + super(packagesDir, processRunner: processRunner) { argParser.addMultiOption('skip', help: 'Skip all linting for podspecs with this basename (example: federated plugins with placeholder podspecs)', diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart index f834c8aa502e..f6b186e7ba2f 100644 --- a/script/tool/lib/src/list_command.dart +++ b/script/tool/lib/src/list_command.dart @@ -12,8 +12,7 @@ import 'common.dart'; class ListCommand extends PluginCommand { /// Creates an instance of the list command, whose behavior depends on the /// 'type' argument it provides. - ListCommand(Directory packagesDir, FileSystem fileSystem) - : super(packagesDir, fileSystem) { + ListCommand(Directory packagesDir) : super(packagesDir) { argParser.addOption( _type, defaultsTo: _plugin, diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index a3fbc34ae8ab..9f1a1d5bf240 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -9,7 +9,6 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; -import 'package:path/path.dart' as p; import 'analyze_command.dart'; import 'build_examples_command.dart'; @@ -32,11 +31,11 @@ import 'xctest_command.dart'; void main(List args) { const FileSystem fileSystem = LocalFileSystem(); - Directory packagesDir = fileSystem - .directory(p.join(fileSystem.currentDirectory.path, 'packages')); + Directory packagesDir = + fileSystem.currentDirectory.childDirectory('packages'); if (!packagesDir.existsSync()) { - if (p.basename(fileSystem.currentDirectory.path) == 'packages') { + if (fileSystem.currentDirectory.basename == 'packages') { packagesDir = fileSystem.currentDirectory; } else { print('Error: Cannot find a "packages" sub-directory'); @@ -47,22 +46,22 @@ void main(List args) { final CommandRunner commandRunner = CommandRunner( 'pub global run flutter_plugin_tools', 'Productivity utils for hosting multiple plugins within one repository.') - ..addCommand(AnalyzeCommand(packagesDir, fileSystem)) - ..addCommand(BuildExamplesCommand(packagesDir, fileSystem)) - ..addCommand(CreateAllPluginsAppCommand(packagesDir, fileSystem)) - ..addCommand(DriveExamplesCommand(packagesDir, fileSystem)) - ..addCommand(FirebaseTestLabCommand(packagesDir, fileSystem)) - ..addCommand(FormatCommand(packagesDir, fileSystem)) - ..addCommand(JavaTestCommand(packagesDir, fileSystem)) - ..addCommand(LicenseCheckCommand(packagesDir, fileSystem)) - ..addCommand(LintPodspecsCommand(packagesDir, fileSystem)) - ..addCommand(ListCommand(packagesDir, fileSystem)) - ..addCommand(PublishCheckCommand(packagesDir, fileSystem)) - ..addCommand(PublishPluginCommand(packagesDir, fileSystem)) - ..addCommand(PubspecCheckCommand(packagesDir, fileSystem)) - ..addCommand(TestCommand(packagesDir, fileSystem)) - ..addCommand(VersionCheckCommand(packagesDir, fileSystem)) - ..addCommand(XCTestCommand(packagesDir, fileSystem)); + ..addCommand(AnalyzeCommand(packagesDir)) + ..addCommand(BuildExamplesCommand(packagesDir)) + ..addCommand(CreateAllPluginsAppCommand(packagesDir)) + ..addCommand(DriveExamplesCommand(packagesDir)) + ..addCommand(FirebaseTestLabCommand(packagesDir)) + ..addCommand(FormatCommand(packagesDir)) + ..addCommand(JavaTestCommand(packagesDir)) + ..addCommand(LicenseCheckCommand(packagesDir)) + ..addCommand(LintPodspecsCommand(packagesDir)) + ..addCommand(ListCommand(packagesDir)) + ..addCommand(PublishCheckCommand(packagesDir)) + ..addCommand(PublishPluginCommand(packagesDir)) + ..addCommand(PubspecCheckCommand(packagesDir)) + ..addCommand(TestCommand(packagesDir)) + ..addCommand(VersionCheckCommand(packagesDir)) + ..addCommand(XCTestCommand(packagesDir)); commandRunner.run(args).catchError((Object e) { final ToolExit toolExit = e as ToolExit; diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index 7a080f44207c..fa229cabefcc 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -21,13 +21,12 @@ import 'common.dart'; class PublishCheckCommand extends PluginCommand { /// Creates an instance of the publish command. PublishCheckCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), this.httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), - super(packagesDir, fileSystem, processRunner: processRunner) { + super(packagesDir, processRunner: processRunner) { argParser.addFlag( _allowPrereleaseFlag, help: 'Allows the pre-release SDK warning to pass.\n' diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index cb72c1897215..6b837cb3f4fa 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -32,16 +32,14 @@ import 'common.dart'; class PublishPluginCommand extends PluginCommand { /// Creates an instance of the publish command. PublishPluginCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Print print = print, io.Stdin stdinput, GitDir gitDir, }) : _print = print, _stdin = stdinput ?? io.stdin, - super(packagesDir, fileSystem, - processRunner: processRunner, gitDir: gitDir) { + super(packagesDir, processRunner: processRunner, gitDir: gitDir) { argParser.addOption( _packageOption, help: 'The package to publish.' @@ -133,12 +131,15 @@ class PublishPluginCommand extends PluginCommand { } _print('Checking local repo...'); - if (!await GitDir.isGitDir(packagesDir.path)) { - _print('$packagesDir is not a valid Git repository.'); + // Ensure there are no symlinks in the path, as it can break + // GitDir's allowSubdirectory:true. + final String packagesPath = packagesDir.resolveSymbolicLinksSync(); + if (!await GitDir.isGitDir(packagesPath)) { + _print('$packagesPath is not a valid Git repository.'); throw ToolExit(1); } final GitDir baseGitDir = - await GitDir.fromExisting(packagesDir.path, allowSubdirectory: true); + await GitDir.fromExisting(packagesPath, allowSubdirectory: true); final bool shouldPushTag = getBoolArg(_pushTagsOption); final String remote = getStringArg(_remoteOption); @@ -194,8 +195,9 @@ class PublishPluginCommand extends PluginCommand { final List packagesFailed = []; for (final String pubspecPath in changedPubspecs) { - final File pubspecFile = - fileSystem.directory(baseGitDir.path).childFile(pubspecPath); + final File pubspecFile = packagesDir.fileSystem + .directory(baseGitDir.path) + .childFile(pubspecPath); final _CheckNeedsReleaseResult result = await _checkNeedsRelease( pubspecFile: pubspecFile, gitVersionFinder: gitVersionFinder, @@ -453,8 +455,7 @@ Safe to ignore if the package is deleted in this commit. } String _getTag(Directory packageDir) { - final File pubspecFile = - fileSystem.file(p.join(packageDir.path, 'pubspec.yaml')); + final File pubspecFile = packageDir.childFile('pubspec.yaml'); final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; final String name = pubspecYaml['name'] as String; @@ -499,7 +500,7 @@ Safe to ignore if the package is deleted in this commit. } void _ensureValidPubCredential() { - final File credentialFile = fileSystem.file(_credentialsPath); + final File credentialFile = packagesDir.fileSystem.file(_credentialsPath); if (credentialFile.existsSync() && credentialFile.readAsStringSync().isNotEmpty) { return; diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index fadcfbc56deb..878b683dbbb8 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -19,12 +19,10 @@ import 'common.dart'; class PubspecCheckCommand extends PluginCommand { /// Creates an instance of the version check command. PubspecCheckCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), GitDir? gitDir, - }) : super(packagesDir, fileSystem, - processRunner: processRunner, gitDir: gitDir); + }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); // Section order for plugins. Because the 'flutter' section is critical // information for plugins, and usually small, it goes near the top unlike in diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart index fafa0f55a22d..0174b986eb63 100644 --- a/script/tool/lib/src/test_command.dart +++ b/script/tool/lib/src/test_command.dart @@ -13,10 +13,9 @@ import 'common.dart'; class TestCommand extends PluginCommand { /// Creates an instance of the test command. TestCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addOption( kEnableExperiment, defaultsTo: '', @@ -37,7 +36,7 @@ class TestCommand extends PluginCommand { await for (final Directory packageDir in getPackages()) { final String packageName = p.relative(packageDir.path, from: packagesDir.path); - if (!fileSystem.directory(p.join(packageDir.path, 'test')).existsSync()) { + if (!packageDir.childDirectory('test').existsSync()) { print('SKIPPING $packageName - no test subdirectory'); continue; } @@ -48,7 +47,7 @@ class TestCommand extends PluginCommand { // `flutter test` automatically gets packages. `pub run test` does not. :( int exitCode = 0; - if (isFlutterPackage(packageDir, fileSystem)) { + if (isFlutterPackage(packageDir)) { final List args = [ 'test', '--color', @@ -56,7 +55,7 @@ class TestCommand extends PluginCommand { '--enable-experiment=$enableExperiment', ]; - if (isWebPlugin(packageDir, fileSystem)) { + if (isWebPlugin(packageDir)) { args.add('--platform=chrome'); } exitCode = await processRunner.runAndStream( diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index c095273a7721..d74c7dad3c55 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -73,15 +73,13 @@ Map getAllowedNextVersions( class VersionCheckCommand extends PluginCommand { /// Creates an instance of the version check command. VersionCheckCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), GitDir gitDir, this.httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), - super(packagesDir, fileSystem, - processRunner: processRunner, gitDir: gitDir) { + super(packagesDir, processRunner: processRunner, gitDir: gitDir) { argParser.addFlag( _againstPubFlag, help: 'Whether the version check should run against the version on pub.\n' @@ -117,7 +115,7 @@ class VersionCheckCommand extends PluginCommand { const String indentation = ' '; for (final String pubspecPath in changedPubspecs) { print('Checking versions for $pubspecPath...'); - final File pubspecFile = fileSystem.file(pubspecPath); + final File pubspecFile = packagesDir.fileSystem.file(pubspecPath); if (!pubspecFile.existsSync()) { print('${indentation}Deleted; skipping.'); continue; diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 2bd8639cd850..e41164e3ed8d 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -26,10 +26,9 @@ const String _kFoundNoSimulatorsMessage = class XCTestCommand extends PluginCommand { /// Creates an instance of the test command. XCTestCommand( - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addOption( _kiOSDestination, help: @@ -68,7 +67,7 @@ class XCTestCommand extends PluginCommand { final String packageName = p.relative(plugin.path, from: packagesDir.path); print('Start running for $packageName ...'); - if (!isIosPlugin(plugin, fileSystem)) { + if (!isIosPlugin(plugin)) { print('iOS is not supported by this plugin.'); print('\n\n'); continue; diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index b536c2bb989c..28cfeaaf3933 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -18,9 +18,8 @@ void main() { setUp(() { initializeFakePackages(); processRunner = RecordingProcessRunner(); - final AnalyzeCommand analyzeCommand = AnalyzeCommand( - mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final AnalyzeCommand analyzeCommand = + AnalyzeCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner('analyze_command', 'Test for analyze_command'); runner.addCommand(analyzeCommand); diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 44634c251752..d162806ab2dd 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -21,9 +21,8 @@ void main() { setUp(() { initializeFakePackages(); processRunner = RecordingProcessRunner(); - final BuildExamplesCommand command = BuildExamplesCommand( - mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final BuildExamplesCommand command = + BuildExamplesCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner( 'build_examples_command', 'Test for build_example_command'); diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart index faf04faaeec0..53fd0ec47218 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common_test.dart @@ -58,7 +58,6 @@ void main() { final SamplePluginCommand samplePluginCommand = SamplePluginCommand( plugins, packagesDir, - fileSystem, processRunner: processRunner, gitDir: gitDir, ); @@ -156,8 +155,7 @@ void main() { expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); - test('all plugins should be tested if .cirrus.yml changes.', - () async { + test('all plugins should be tested if .cirrus.yml changes.', () async { gitDiffResponse = ''' .cirrus.yml packages/plugin1/CHANGELOG @@ -172,8 +170,7 @@ packages/plugin1/CHANGELOG expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); - test('all plugins should be tested if .ci.yaml changes', - () async { + test('all plugins should be tested if .ci.yaml changes', () async { gitDiffResponse = ''' .ci.yaml packages/plugin1/CHANGELOG @@ -188,8 +185,7 @@ packages/plugin1/CHANGELOG expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); - test('all plugins should be tested if anything in .ci/ changes', - () async { + test('all plugins should be tested if anything in .ci/ changes', () async { gitDiffResponse = ''' .ci/Dockerfile packages/plugin1/CHANGELOG @@ -520,12 +516,10 @@ file2/file2.cc class SamplePluginCommand extends PluginCommand { SamplePluginCommand( this._plugins, - Directory packagesDir, - FileSystem fileSystem, { + Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), GitDir? gitDir, - }) : super(packagesDir, fileSystem, - processRunner: processRunner, gitDir: gitDir); + }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); final List _plugins; diff --git a/script/tool/test/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index caf4218dc161..e9da3cb1ef8a 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -30,7 +30,6 @@ void main() { final CreateAllPluginsAppCommand command = CreateAllPluginsAppCommand( packagesDir, - fileSystem, pluginsRoot: testRoot, ); appDir = command.appDirectory; diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index b4d6a25154c5..85bd4f019a4f 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -21,9 +21,8 @@ void main() { setUp(() { initializeFakePackages(); processRunner = RecordingProcessRunner(); - final DriveExamplesCommand command = DriveExamplesCommand( - mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final DriveExamplesCommand command = + DriveExamplesCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner( 'drive_examples_command', 'Test for drive_example_command'); diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart index b4e5b5b8c723..f8ddc9fa4711 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_test.dart @@ -24,7 +24,7 @@ void main() { initializeFakePackages(); processRunner = RecordingProcessRunner(); final FirebaseTestLabCommand command = FirebaseTestLabCommand( - mockPackagesDir, mockFileSystem, + mockPackagesDir, processRunner: processRunner, print: (Object message) => printedMessages.add(message.toString())); diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index 2ce231651b87..24e85429c1e1 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -17,9 +17,8 @@ void main() { setUp(() { initializeFakePackages(); - final JavaTestCommand command = JavaTestCommand( - mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final JavaTestCommand command = + JavaTestCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner('java_test_test', 'Test for $JavaTestCommand'); diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart index 7b49fa8e9ed6..a874d7db17b7 100644 --- a/script/tool/test/license_check_command_test.dart +++ b/script/tool/test/license_check_command_test.dart @@ -25,7 +25,6 @@ void main() { printedMessages = []; final LicenseCheckCommand command = LicenseCheckCommand( packagesDir, - fileSystem, print: (Object? message) => printedMessages.add(message.toString()), ); runner = diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index fad750393a78..4cb416f0bab6 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -30,7 +30,6 @@ void main() { when(mockPlatform.isMacOS).thenReturn(true); final LintPodspecsCommand command = LintPodspecsCommand( mockPackagesDir, - mockFileSystem, processRunner: processRunner, platform: mockPlatform, print: (Object message) => printedMessages.add(message.toString()), diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart index e97474dedfe9..ca0dbc614e9f 100644 --- a/script/tool/test/list_command_test.dart +++ b/script/tool/test/list_command_test.dart @@ -15,7 +15,7 @@ void main() { setUp(() { initializeFakePackages(); - final ListCommand command = ListCommand(mockPackagesDir, mockFileSystem); + final ListCommand command = ListCommand(mockPackagesDir); runner = CommandRunner('list_test', 'Test for $ListCommand'); runner.addCommand(command); diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 38e8504fa053..cccff19de5e3 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -27,9 +27,8 @@ void main() { setUp(() { initializeFakePackages(); processRunner = PublishCheckProcessRunner(); - final PublishCheckCommand publishCheckCommand = PublishCheckCommand( - mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final PublishCheckCommand publishCheckCommand = + PublishCheckCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner( 'publish_check_command', @@ -146,8 +145,8 @@ void main() { processRunner.processesToReturn.add(process); - final List output = await runCapturingPrint( - runner, ['publish-check']); + final List output = + await runCapturingPrint(runner, ['publish-check']); expect(output, isNot(contains(contains('ERROR:')))); }); @@ -180,8 +179,7 @@ void main() { } return null; }); - final PublishCheckCommand command = PublishCheckCommand( - mockPackagesDir, mockFileSystem, + final PublishCheckCommand command = PublishCheckCommand(mockPackagesDir, processRunner: processRunner, httpClient: mockClient); runner = CommandRunner( @@ -247,8 +245,7 @@ void main() { } return null; }); - final PublishCheckCommand command = PublishCheckCommand( - mockPackagesDir, mockFileSystem, + final PublishCheckCommand command = PublishCheckCommand(mockPackagesDir, processRunner: processRunner, httpClient: mockClient); runner = CommandRunner( @@ -317,8 +314,7 @@ void main() { } return null; }); - final PublishCheckCommand command = PublishCheckCommand( - mockPackagesDir, mockFileSystem, + final PublishCheckCommand command = PublishCheckCommand(mockPackagesDir, processRunner: processRunner, httpClient: mockClient); runner = CommandRunner( diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 3618cc38bc81..1bf6ab7bbe74 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -45,6 +45,9 @@ void main() { setUp(() async { parentDir = fileSystem.systemTempDirectory .createTempSync('publish_plugin_command_test-'); + // The temp directory can have symbolic links, which won't match git output; + // use a fully resolved version to avoid potential path comparison issues. + parentDir = fileSystem.directory(parentDir.resolveSymbolicLinksSync()); initializeFakePackages(parentDir: parentDir); pluginDir = createFakePlugin(testPluginName, withSingleExample: false, packagesDirectory: parentDir); @@ -58,7 +61,7 @@ void main() { processRunner = TestProcessRunner(); mockStdin = MockStdin(); commandRunner = CommandRunner('tester', '') - ..addCommand(PublishPluginCommand(parentDir, fileSystem, + ..addCommand(PublishPluginCommand(parentDir, processRunner: processRunner, print: (Object message) => printedMessages.add(message.toString()), stdinput: mockStdin, diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index aee3f7a20310..e1b1d364978f 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -23,9 +23,8 @@ void main() { packagesDir = fileSystem.currentDirectory.childDirectory('packages'); initializeFakePackages(parentDir: packagesDir.parent); processRunner = RecordingProcessRunner(); - final PubspecCheckCommand command = PubspecCheckCommand( - packagesDir, fileSystem, - processRunner: processRunner); + final PubspecCheckCommand command = + PubspecCheckCommand(packagesDir, processRunner: processRunner); runner = CommandRunner( 'pubspec_check_command', 'Test for pubspec_check_command'); diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 1c7118abc618..61c06e0af1b7 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -16,8 +16,8 @@ void main() { setUp(() { initializeFakePackages(); - final TestCommand command = TestCommand(mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final TestCommand command = + TestCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner('test_test', 'Test for $TestCommand'); runner.addCommand(command); diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index 536b885dd578..600d9a08c3fe 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -88,8 +88,7 @@ void main() { }); initializeFakePackages(); processRunner = RecordingProcessRunner(); - final VersionCheckCommand command = VersionCheckCommand( - mockPackagesDir, mockFileSystem, + final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, processRunner: processRunner, gitDir: gitDir); runner = CommandRunner( @@ -238,13 +237,10 @@ void main() { }); test('gracefully handles missing pubspec.yaml', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + final Directory pluginDir = createFakePlugin('plugin', + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; - mockFileSystem.currentDirectory - .childDirectory('packages') - .childDirectory('plugin') - .childFile('pubspec.yaml') - .deleteSync(); + pluginDir.childFile('pubspec.yaml').deleteSync(); final List output = await runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -600,8 +596,7 @@ The first version listed in CHANGELOG.md is 1.0.0. final MockClient mockClient = MockClient((http.Request request) async { return http.Response(json.encode(httpResponse), 200); }); - final VersionCheckCommand command = VersionCheckCommand( - mockPackagesDir, mockFileSystem, + final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( @@ -637,8 +632,7 @@ The first version listed in CHANGELOG.md is 1.0.0. final MockClient mockClient = MockClient((http.Request request) async { return http.Response(json.encode(httpResponse), 200); }); - final VersionCheckCommand command = VersionCheckCommand( - mockPackagesDir, mockFileSystem, + final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( @@ -682,8 +676,7 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N final MockClient mockClient = MockClient((http.Request request) async { return http.Response('xx', 400); }); - final VersionCheckCommand command = VersionCheckCommand( - mockPackagesDir, mockFileSystem, + final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( @@ -726,8 +719,7 @@ ${indentation}HTTP response: xx final MockClient mockClient = MockClient((http.Request request) async { return http.Response('xx', 404); }); - final VersionCheckCommand command = VersionCheckCommand( - mockPackagesDir, mockFileSystem, + final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 505730f37a6e..0b25a5b015c1 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -91,9 +91,8 @@ void main() { setUp(() { initializeFakePackages(); processRunner = RecordingProcessRunner(); - final XCTestCommand command = XCTestCommand( - mockPackagesDir, mockFileSystem, - processRunner: processRunner); + final XCTestCommand command = + XCTestCommand(mockPackagesDir, processRunner: processRunner); runner = CommandRunner('xctest_command', 'Test for xctest_command'); runner.addCommand(command); From 464303ff334e5ce5a5d68e7059a5d88b95705c9b Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 7 Jun 2021 10:04:43 -0700 Subject: [PATCH 021/364] [flutter_plugin_tools] Remove global state from tests (#4018) Eliminates the global test filesystem and global test packages directory, in favor of local versions. This guarantees that each test runs with a clean filesystem state, rather than relying on cleanup. It also simplifies understanding the tests, since everything is done via params and return values instead of needing to know about the magic global variables and which methods mutate them. --- script/tool/test/analyze_command_test.dart | 33 +++-- .../test/build_examples_command_test.dart | 113 +++++++-------- script/tool/test/common_test.dart | 137 +++++++----------- .../create_all_plugins_app_command_test.dart | 14 +- .../test/drive_examples_command_test.dart | 82 ++++++----- script/tool/test/firebase_test_lab_test.dart | 33 ++--- script/tool/test/java_test_command_test.dart | 18 ++- .../tool/test/lint_podspecs_command_test.dart | 50 +++---- script/tool/test/list_command_test.dart | 52 +++---- .../tool/test/publish_check_command_test.dart | 44 +++--- .../test/publish_plugin_command_test.dart | 115 +++++++-------- .../tool/test/pubspec_check_command_test.dart | 42 +++--- script/tool/test/test_command_test.dart | 59 ++++---- script/tool/test/util.dart | 47 +++--- script/tool/test/version_check_test.dart | 101 +++++++------ script/tool/test/xctest_command_test.dart | 53 ++++--- 16 files changed, 456 insertions(+), 537 deletions(-) diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 28cfeaaf3933..ec627f25864c 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/analyze_command.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:test/test.dart'; @@ -12,26 +13,25 @@ import 'mocks.dart'; import 'util.dart'; void main() { + late FileSystem fileSystem; + late Directory packagesDir; late RecordingProcessRunner processRunner; late CommandRunner runner; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); final AnalyzeCommand analyzeCommand = - AnalyzeCommand(mockPackagesDir, processRunner: processRunner); + AnalyzeCommand(packagesDir, processRunner: processRunner); runner = CommandRunner('analyze_command', 'Test for analyze_command'); runner.addCommand(analyzeCommand); }); - tearDown(() { - mockPackagesDir.deleteSync(recursive: true); - }); - test('analyzes all packages', () async { - final Directory plugin1Dir = createFakePlugin('a'); - final Directory plugin2Dir = createFakePlugin('b'); + final Directory plugin1Dir = createFakePlugin('a', packagesDir); + final Directory plugin2Dir = createFakePlugin('b', packagesDir); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -53,7 +53,8 @@ void main() { }); test('skips flutter pub get for examples', () async { - final Directory plugin1Dir = createFakePlugin('a', withSingleExample: true); + final Directory plugin1Dir = + createFakePlugin('a', packagesDir, withSingleExample: true); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -71,8 +72,8 @@ void main() { }); test('don\'t elide a non-contained example package', () async { - final Directory plugin1Dir = createFakePlugin('a'); - final Directory plugin2Dir = createFakePlugin('example'); + final Directory plugin1Dir = createFakePlugin('a', packagesDir); + final Directory plugin2Dir = createFakePlugin('example', packagesDir); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -94,7 +95,7 @@ void main() { }); test('uses a separate analysis sdk', () async { - final Directory pluginDir = createFakePlugin('a'); + final Directory pluginDir = createFakePlugin('a', packagesDir); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -120,7 +121,7 @@ void main() { group('verifies analysis settings', () { test('fails analysis_options.yaml', () async { - createFakePlugin('foo', withExtraFiles: >[ + createFakePlugin('foo', packagesDir, withExtraFiles: >[ ['analysis_options.yaml'] ]); @@ -129,7 +130,7 @@ void main() { }); test('fails .analysis_options', () async { - createFakePlugin('foo', withExtraFiles: >[ + createFakePlugin('foo', packagesDir, withExtraFiles: >[ ['.analysis_options'] ]); @@ -139,7 +140,7 @@ void main() { test('takes an allow list', () async { final Directory pluginDir = - createFakePlugin('foo', withExtraFiles: >[ + createFakePlugin('foo', packagesDir, withExtraFiles: >[ ['analysis_options.yaml'] ]); @@ -160,7 +161,7 @@ void main() { // See: https://github.com/flutter/flutter/issues/78994 test('takes an empty allow list', () async { - createFakePlugin('foo', withExtraFiles: >[ + createFakePlugin('foo', packagesDir, withExtraFiles: >[ ['analysis_options.yaml'] ]); diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index d162806ab2dd..2ad17b374ba7 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/build_examples_command.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; @@ -13,40 +14,42 @@ import 'util.dart'; void main() { group('test build_example_command', () { + late FileSystem fileSystem; + late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; final String flutterCommand = const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); final BuildExamplesCommand command = - BuildExamplesCommand(mockPackagesDir, processRunner: processRunner); + BuildExamplesCommand(packagesDir, processRunner: processRunner); runner = CommandRunner( 'build_examples_command', 'Test for build_example_command'); runner.addCommand(command); - cleanupPackages(); }); test('building for iOS when plugin is not set up for iOS results in no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isLinuxPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--ipa', '--no-macos']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -61,18 +64,17 @@ void main() { // Output should be empty since running build-examples --macos with no macos // implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); - cleanupPackages(); }); test('building for ios', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -83,7 +85,7 @@ void main() { '--enable-experiment=exp1' ]); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -107,27 +109,26 @@ void main() { ], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test( 'building for Linux when plugin is not set up for Linux results in no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isLinuxPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--linux']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -142,25 +143,24 @@ void main() { // Output should be empty since running build-examples --linux with no // Linux implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); - cleanupPackages(); }); test('building for Linux', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isLinuxPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--linux']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -177,24 +177,24 @@ void main() { ProcessCall(flutterCommand, const ['build', 'linux'], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test('building for macos with no implementation results in no-op', () async { - createFakePlugin('plugin', withExtraFiles: >[ - ['example', 'test'], - ]); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--macos']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -209,11 +209,10 @@ void main() { // Output should be empty since running build-examples --macos with no macos // implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); - cleanupPackages(); }); test('building for macos', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ['example', 'macos', 'macos.swift'], @@ -221,14 +220,14 @@ void main() { isMacOsPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--macos']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -245,23 +244,23 @@ void main() { ProcessCall(flutterCommand, const ['build', 'macos'], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test('building for web with no implementation results in no-op', () async { - createFakePlugin('plugin', withExtraFiles: >[ - ['example', 'test'], - ]); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--web']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -276,11 +275,10 @@ void main() { // Output should be empty since running build-examples --macos with no macos // implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); - cleanupPackages(); }); test('building for web', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ['example', 'web', 'index.html'], @@ -288,14 +286,14 @@ void main() { isWebPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--web']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -312,27 +310,26 @@ void main() { ProcessCall(flutterCommand, const ['build', 'web'], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test( 'building for Windows when plugin is not set up for Windows results in no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isWindowsPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--windows']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -347,25 +344,24 @@ void main() { // Output should be empty since running build-examples --macos with no macos // implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); - cleanupPackages(); }); test('building for windows', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isWindowsPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--windows']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -382,27 +378,26 @@ void main() { ProcessCall(flutterCommand, const ['build', 'windows'], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test( 'building for Android when plugin is not set up for Android results in no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isLinuxPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); final List output = await runCapturingPrint( runner, ['build-examples', '--apk', '--no-ipa']); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -417,18 +412,17 @@ void main() { // Output should be empty since running build-examples --macos with no macos // implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); - cleanupPackages(); }); test('building for android', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isAndroidPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -439,7 +433,7 @@ void main() { '--no-macos', ]); final String packageName = - p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path); + p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, @@ -456,18 +450,17 @@ void main() { ProcessCall(flutterCommand, const ['build', 'apk'], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test('enable-experiment flag for Android', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isAndroidPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -487,18 +480,17 @@ void main() { const ['build', 'apk', '--enable-experiment=exp1'], pluginExampleDirectory.path), ])); - cleanupPackages(); }); test('enable-experiment flag for ios', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -521,7 +513,6 @@ void main() { ], pluginExampleDirectory.path), ])); - cleanupPackages(); }); }); } diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart index 53fd0ec47218..2f497963d229 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common_test.dart @@ -34,7 +34,7 @@ void main() { setUp(() { fileSystem = MemoryFileSystem(); - packagesDir = fileSystem.currentDirectory.childDirectory('packages'); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); thirdPartyPackagesDir = packagesDir.parent .childDirectory('third_party') .childDirectory('packages'); @@ -52,7 +52,6 @@ void main() { } return Future.value(mockProcessResult); }); - initializeFakePackages(parentDir: packagesDir.parent); processRunner = RecordingProcessRunner(); plugins = []; final SamplePluginCommand samplePluginCommand = SamplePluginCommand( @@ -67,47 +66,40 @@ void main() { }); test('all plugins from file system', () async { - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run(['sample']); expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins includes third_party/packages', () async { - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); final Directory plugin3 = - createFakePlugin('plugin3', packagesDirectory: thirdPartyPackagesDir); + createFakePlugin('plugin3', thirdPartyPackagesDir); await runner.run(['sample']); expect(plugins, unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); }); test('exclude plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']); expect(plugins, unorderedEquals([plugin2.path])); }); test('exclude plugins when plugins flag isn\'t specified', () async { - createFakePlugin('plugin1', packagesDirectory: packagesDir); - createFakePlugin('plugin2', packagesDirectory: packagesDir); + createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); await runner.run(['sample', '--exclude=plugin1,plugin2']); expect(plugins, unorderedEquals([])); }); test('exclude federated plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', - parentDirectoryName: 'federated', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + createFakePlugin('plugin1', packagesDir, parentDirectoryName: 'federated'); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run([ 'sample', '--plugins=federated/plugin1,plugin2', @@ -118,10 +110,8 @@ void main() { test('exclude entire federated plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', - parentDirectoryName: 'federated', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + createFakePlugin('plugin1', packagesDir, parentDirectoryName: 'federated'); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run([ 'sample', '--plugins=federated/plugin1,plugin2', @@ -132,10 +122,8 @@ void main() { group('test run-on-changed-packages', () { test('all plugins should be tested if there are no changes.', () async { - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -145,10 +133,8 @@ void main() { test('all plugins should be tested if there are no plugin related changes.', () async { gitDiffResponse = 'AUTHORS'; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -160,10 +146,8 @@ void main() { .cirrus.yml packages/plugin1/CHANGELOG '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -175,10 +159,8 @@ packages/plugin1/CHANGELOG .ci.yaml packages/plugin1/CHANGELOG '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -190,10 +172,8 @@ packages/plugin1/CHANGELOG .ci/Dockerfile packages/plugin1/CHANGELOG '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -206,10 +186,8 @@ packages/plugin1/CHANGELOG script/tool_runner.sh packages/plugin1/CHANGELOG '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -222,10 +200,8 @@ packages/plugin1/CHANGELOG analysis_options.yaml packages/plugin1/CHANGELOG '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -238,10 +214,8 @@ packages/plugin1/CHANGELOG .clang-format packages/plugin1/CHANGELOG '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -250,9 +224,8 @@ packages/plugin1/CHANGELOG test('Only changed plugin should be tested.', () async { gitDiffResponse = 'packages/plugin1/plugin1.dart'; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -264,9 +237,8 @@ packages/plugin1/CHANGELOG packages/plugin1/plugin1.dart packages/plugin1/ios/plugin1.m '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - createFakePlugin('plugin2', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -279,11 +251,9 @@ packages/plugin1/ios/plugin1.m packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m '''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); - createFakePlugin('plugin3', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -298,10 +268,10 @@ packages/plugin1/plugin1/plugin1.dart packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart packages/plugin1/plugin1_web/plugin1_web.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', - parentDirectoryName: 'plugin1', packagesDirectory: packagesDir); - createFakePlugin('plugin2', packagesDirectory: packagesDir); - createFakePlugin('plugin3', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'plugin1'); + createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); await runner.run( ['sample', '--base-sha=master', '--run-on-changed-packages']); @@ -315,11 +285,10 @@ packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', - parentDirectoryName: 'plugin1', packagesDirectory: packagesDir); - final Directory plugin2 = - createFakePlugin('plugin2', packagesDirectory: packagesDir); - createFakePlugin('plugin3', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'plugin1'); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); await runner.run([ 'sample', '--plugins=plugin1,plugin2', @@ -336,10 +305,10 @@ packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', - parentDirectoryName: 'plugin1', packagesDirectory: packagesDir); - createFakePlugin('plugin2', packagesDirectory: packagesDir); - createFakePlugin('plugin3', packagesDirectory: packagesDir); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'plugin1'); + createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); await runner.run([ 'sample', '--exclude=plugin2,plugin3', @@ -352,12 +321,15 @@ packages/plugin3/plugin3.dart }); group('$GitVersionFinder', () { + late FileSystem fileSystem; late List?> gitDirCommands; late String gitDiffResponse; String? mergeBaseResponse; late MockGitDir gitDir; setUp(() { + fileSystem = MemoryFileSystem(); + createPackagesDirectory(fileSystem: fileSystem); gitDirCommands = ?>[]; gitDiffResponse = ''; gitDir = MockGitDir(); @@ -374,14 +346,9 @@ packages/plugin3/plugin3.dart } return Future.value(mockProcessResult); }); - initializeFakePackages(); processRunner = RecordingProcessRunner(); }); - tearDown(() { - cleanupPackages(); - }); - test('No git diff should result no files changed', () async { final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); final List changedFiles = await finder.getChangedFiles(); diff --git a/script/tool/test/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index e9da3cb1ef8a..b3cbd592a631 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -43,9 +43,9 @@ void main() { }); test('pubspec includes all plugins', () async { - createFakePlugin('plugina', packagesDirectory: packagesDir); - createFakePlugin('pluginb', packagesDirectory: packagesDir); - createFakePlugin('pluginc', packagesDirectory: packagesDir); + createFakePlugin('plugina', packagesDir); + createFakePlugin('pluginb', packagesDir); + createFakePlugin('pluginc', packagesDir); await runner.run(['all-plugins-app']); final List pubspec = @@ -61,9 +61,9 @@ void main() { }); test('pubspec has overrides for all plugins', () async { - createFakePlugin('plugina', packagesDirectory: packagesDir); - createFakePlugin('pluginb', packagesDirectory: packagesDir); - createFakePlugin('pluginc', packagesDirectory: packagesDir); + createFakePlugin('plugina', packagesDir); + createFakePlugin('pluginb', packagesDir); + createFakePlugin('pluginc', packagesDir); await runner.run(['all-plugins-app']); final List pubspec = @@ -80,7 +80,7 @@ void main() { }); test('pubspec is compatible with null-safe app code', () async { - createFakePlugin('plugina', packagesDirectory: packagesDir); + createFakePlugin('plugina', packagesDir); await runner.run(['all-plugins-app']); final String pubspec = diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 85bd4f019a4f..c9a8b9d90a83 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/drive_examples_command.dart'; import 'package:path/path.dart' as p; @@ -14,27 +15,27 @@ import 'util.dart'; void main() { group('test drive_example_command', () { + late FileSystem fileSystem; + late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; final String flutterCommand = const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; + setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); final DriveExamplesCommand command = - DriveExamplesCommand(mockPackagesDir, processRunner: processRunner); + DriveExamplesCommand(packagesDir, processRunner: processRunner); runner = CommandRunner( 'drive_examples_command', 'Test for drive_example_command'); runner.addCommand(command); }); - tearDown(() { - cleanupPackages(); - }); - test('driving under folder "test"', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test', 'plugin.dart'], @@ -43,7 +44,7 @@ void main() { isAndroidPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -79,7 +80,7 @@ void main() { }); test('driving under folder "test_driver"', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -88,7 +89,7 @@ void main() { isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -125,7 +126,7 @@ void main() { test('driving under folder "test_driver" when test files are missing"', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ], @@ -133,7 +134,7 @@ void main() { isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -144,7 +145,7 @@ void main() { test('a plugin without any integration test files is reported as an error', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'lib', 'main.dart'], ], @@ -152,7 +153,7 @@ void main() { isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -164,7 +165,7 @@ void main() { test( 'driving under folder "test_driver" when targets are under "integration_test"', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'integration_test.dart'], ['example', 'integration_test', 'bar_test.dart'], @@ -175,7 +176,7 @@ void main() { isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -221,7 +222,7 @@ void main() { }); test('driving when plugin does not support Linux is a no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -229,7 +230,7 @@ void main() { isMacOsPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -254,7 +255,7 @@ void main() { }); test('driving on a Linux plugin', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -262,7 +263,7 @@ void main() { isLinuxPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -301,13 +302,14 @@ void main() { }); test('driving when plugin does not suppport macOS is a no-op', () async { - createFakePlugin('plugin', withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ]); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ]); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -331,7 +333,7 @@ void main() { expect(processRunner.recordedCalls, []); }); test('driving on a macOS plugin', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -340,7 +342,7 @@ void main() { isMacOsPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -379,7 +381,7 @@ void main() { }); test('driving when plugin does not suppport web is a no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -387,7 +389,7 @@ void main() { isWebPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -412,7 +414,7 @@ void main() { }); test('driving a web plugin', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -420,7 +422,7 @@ void main() { isWebPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -461,7 +463,7 @@ void main() { }); test('driving when plugin does not suppport Windows is a no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -469,7 +471,7 @@ void main() { isWindowsPlugin: false); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -494,7 +496,7 @@ void main() { }); test('driving on a Windows plugin', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -502,7 +504,7 @@ void main() { isWindowsPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -541,7 +543,7 @@ void main() { }); test('driving when plugin does not support mobile is no-op', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test_driver', 'plugin.dart'], @@ -549,7 +551,7 @@ void main() { isMacOsPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -573,7 +575,7 @@ void main() { }); test('platform interface plugins are silently skipped', () async { - createFakePlugin('aplugin_platform_interface'); + createFakePlugin('aplugin_platform_interface', packagesDir); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -593,7 +595,7 @@ void main() { }); test('enable-experiment flag', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test_driver', 'plugin_test.dart'], ['example', 'test', 'plugin.dart'], @@ -602,7 +604,7 @@ void main() { isAndroidPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart index f8ddc9fa4711..74809007c295 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_test.dart @@ -7,6 +7,8 @@ import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/firebase_test_lab_command.dart'; import 'package:test/test.dart'; @@ -16,15 +18,18 @@ import 'util.dart'; void main() { group('$FirebaseTestLabCommand', () { - final List printedMessages = []; + FileSystem fileSystem; + Directory packagesDir; + List printedMessages; CommandRunner runner; RecordingProcessRunner processRunner; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + printedMessages = []; processRunner = RecordingProcessRunner(); - final FirebaseTestLabCommand command = FirebaseTestLabCommand( - mockPackagesDir, + final FirebaseTestLabCommand command = FirebaseTestLabCommand(packagesDir, processRunner: processRunner, print: (Object message) => printedMessages.add(message.toString())); @@ -33,15 +38,11 @@ void main() { runner.addCommand(command); }); - tearDown(() { - printedMessages.clear(); - }); - test('retries gcloud set', () async { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(1); processRunner.processToReturn = mockProcess; - createFakePlugin('plugin', withExtraFiles: >[ + createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['lib/test/should_not_run_e2e.dart'], ['example', 'test_driver', 'plugin_e2e.dart'], ['example', 'test_driver', 'plugin_e2e_test.dart'], @@ -66,7 +67,7 @@ void main() { }); test('runs e2e tests', () async { - createFakePlugin('plugin', withExtraFiles: >[ + createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['test', 'plugin_test.dart'], ['test', 'plugin_e2e.dart'], ['should_not_run_e2e.dart'], @@ -134,7 +135,7 @@ void main() { '/packages/plugin/example'), ProcessCall( '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart' + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart' .split(' '), '/packages/plugin/example/android'), ProcessCall( @@ -144,7 +145,7 @@ void main() { '/packages/plugin/example'), ProcessCall( '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart' + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart' .split(' '), '/packages/plugin/example/android'), ProcessCall( @@ -167,7 +168,7 @@ void main() { }); test('experimental flag', () async { - createFakePlugin('plugin', withExtraFiles: >[ + createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['test', 'plugin_test.dart'], ['test', 'plugin_e2e.dart'], ['should_not_run_e2e.dart'], @@ -225,7 +226,7 @@ void main() { '/packages/plugin/example'), ProcessCall( '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' .split(' '), '/packages/plugin/example/android'), ProcessCall( @@ -235,7 +236,7 @@ void main() { '/packages/plugin/example'), ProcessCall( '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' .split(' '), '/packages/plugin/example/android'), ProcessCall( @@ -255,8 +256,6 @@ void main() { '/packages/plugin/example'), ]), ); - - cleanupPackages(); }); }); } diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index 24e85429c1e1..a1c2d3b864c4 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/java_test_command.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -12,27 +13,27 @@ import 'util.dart'; void main() { group('$JavaTestCommand', () { + late FileSystem fileSystem; + late Directory packagesDir; late CommandRunner runner; - final RecordingProcessRunner processRunner = RecordingProcessRunner(); + late RecordingProcessRunner processRunner; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); final JavaTestCommand command = - JavaTestCommand(mockPackagesDir, processRunner: processRunner); + JavaTestCommand(packagesDir, processRunner: processRunner); runner = CommandRunner('java_test_test', 'Test for $JavaTestCommand'); runner.addCommand(command); }); - tearDown(() { - cleanupPackages(); - processRunner.recordedCalls.clear(); - }); - test('Should run Java tests in Android implementation folder', () async { final Directory plugin = createFakePlugin( 'plugin1', + packagesDir, isAndroidPlugin: true, isFlutter: true, withSingleExample: true, @@ -59,6 +60,7 @@ void main() { test('Should run Java tests in example folder', () async { final Directory plugin = createFakePlugin( 'plugin1', + packagesDir, isAndroidPlugin: true, isFlutter: true, withSingleExample: true, diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 4cb416f0bab6..349607b0ca75 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -6,6 +6,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/lint_podspecs_command.dart'; import 'package:mockito/mockito.dart'; import 'package:path/path.dart' as p; @@ -17,19 +18,22 @@ import 'util.dart'; void main() { group('$LintPodspecsCommand', () { + FileSystem fileSystem; + Directory packagesDir; CommandRunner runner; MockPlatform mockPlatform; final RecordingProcessRunner processRunner = RecordingProcessRunner(); List printedMessages; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); printedMessages = []; mockPlatform = MockPlatform(); when(mockPlatform.isMacOS).thenReturn(true); final LintPodspecsCommand command = LintPodspecsCommand( - mockPackagesDir, + packagesDir, processRunner: processRunner, platform: mockPlatform, print: (Object message) => printedMessages.add(message.toString()), @@ -44,12 +48,8 @@ void main() { processRunner.recordedCalls.clear(); }); - tearDown(() { - cleanupPackages(); - }); - test('only runs on macOS', () async { - createFakePlugin('plugin1', withExtraFiles: >[ + createFakePlugin('plugin1', packagesDir, withExtraFiles: >[ ['plugin1.podspec'], ]); @@ -63,11 +63,11 @@ void main() { }); test('runs pod lib lint on a podspec', () async { - final Directory plugin1Dir = - createFakePlugin('plugin1', withExtraFiles: >[ - ['ios', 'plugin1.podspec'], - ['bogus.dart'], // Ignore non-podspecs. - ]); + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, + withExtraFiles: >[ + ['ios', 'plugin1.podspec'], + ['bogus.dart'], // Ignore non-podspecs. + ]); processRunner.resultStdout = 'Foo'; processRunner.resultStderr = 'Bar'; @@ -77,7 +77,7 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall('which', const ['pod'], mockPackagesDir.path), + ProcessCall('which', const ['pod'], packagesDir.path), ProcessCall( 'pod', [ @@ -89,7 +89,7 @@ void main() { '--use-modular-headers', '--use-libraries' ], - mockPackagesDir.path), + packagesDir.path), ProcessCall( 'pod', [ @@ -100,7 +100,7 @@ void main() { '--skip-tests', '--use-modular-headers', ], - mockPackagesDir.path), + packagesDir.path), ]), ); @@ -110,10 +110,10 @@ void main() { }); test('skips podspecs with known issues', () async { - createFakePlugin('plugin1', withExtraFiles: >[ + createFakePlugin('plugin1', packagesDir, withExtraFiles: >[ ['plugin1.podspec'] ]); - createFakePlugin('plugin2', withExtraFiles: >[ + createFakePlugin('plugin2', packagesDir, withExtraFiles: >[ ['plugin2.podspec'] ]); @@ -123,23 +123,23 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall('which', const ['pod'], mockPackagesDir.path), + ProcessCall('which', const ['pod'], packagesDir.path), ]), ); }); test('allow warnings for podspecs with known warnings', () async { - final Directory plugin1Dir = - createFakePlugin('plugin1', withExtraFiles: >[ - ['plugin1.podspec'], - ]); + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, + withExtraFiles: >[ + ['plugin1.podspec'], + ]); await runner.run(['podspecs', '--ignore-warnings=plugin1']); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall('which', const ['pod'], mockPackagesDir.path), + ProcessCall('which', const ['pod'], packagesDir.path), ProcessCall( 'pod', [ @@ -152,7 +152,7 @@ void main() { '--allow-warnings', '--use-libraries' ], - mockPackagesDir.path), + packagesDir.path), ProcessCall( 'pod', [ @@ -164,7 +164,7 @@ void main() { '--use-modular-headers', '--allow-warnings', ], - mockPackagesDir.path), + packagesDir.path), ]), ); diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart index ca0dbc614e9f..02b898c5c3fc 100644 --- a/script/tool/test/list_command_test.dart +++ b/script/tool/test/list_command_test.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/list_command.dart'; import 'package:test/test.dart'; @@ -11,19 +12,22 @@ import 'util.dart'; void main() { group('$ListCommand', () { + late FileSystem fileSystem; + late Directory packagesDir; late CommandRunner runner; setUp(() { - initializeFakePackages(); - final ListCommand command = ListCommand(mockPackagesDir); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + final ListCommand command = ListCommand(packagesDir); runner = CommandRunner('list_test', 'Test for $ListCommand'); runner.addCommand(command); }); test('lists plugins', () async { - createFakePlugin('plugin1'); - createFakePlugin('plugin2'); + createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); final List plugins = await runCapturingPrint(runner, ['list', '--type=plugin']); @@ -35,15 +39,13 @@ void main() { '/packages/plugin2', ]), ); - - cleanupPackages(); }); test('lists examples', () async { - createFakePlugin('plugin1', withSingleExample: true); - createFakePlugin('plugin2', + createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin2', packagesDir, withExamples: ['example1', 'example2']); - createFakePlugin('plugin3'); + createFakePlugin('plugin3', packagesDir); final List examples = await runCapturingPrint(runner, ['list', '--type=example']); @@ -56,15 +58,13 @@ void main() { '/packages/plugin2/example/example2', ]), ); - - cleanupPackages(); }); test('lists packages', () async { - createFakePlugin('plugin1', withSingleExample: true); - createFakePlugin('plugin2', + createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin2', packagesDir, withExamples: ['example1', 'example2']); - createFakePlugin('plugin3'); + createFakePlugin('plugin3', packagesDir); final List packages = await runCapturingPrint(runner, ['list', '--type=package']); @@ -80,15 +80,13 @@ void main() { '/packages/plugin3', ]), ); - - cleanupPackages(); }); test('lists files', () async { - createFakePlugin('plugin1', withSingleExample: true); - createFakePlugin('plugin2', + createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin2', packagesDir, withExamples: ['example1', 'example2']); - createFakePlugin('plugin3'); + createFakePlugin('plugin3', packagesDir); final List examples = await runCapturingPrint(runner, ['list', '--type=file']); @@ -104,17 +102,15 @@ void main() { '/packages/plugin3/pubspec.yaml', ]), ); - - cleanupPackages(); }); test('lists plugins using federated plugin layout', () async { - createFakePlugin('plugin1'); + createFakePlugin('plugin1', packagesDir); // Create a federated plugin by creating a directory under the packages // directory with several packages underneath. - final Directory federatedPlugin = - mockPackagesDir.childDirectory('my_plugin')..createSync(); + final Directory federatedPlugin = packagesDir.childDirectory('my_plugin') + ..createSync(); final Directory clientLibrary = federatedPlugin.childDirectory('my_plugin')..createSync(); createFakePubspec(clientLibrary); @@ -138,17 +134,15 @@ void main() { '/packages/my_plugin/my_plugin_macos', ]), ); - - cleanupPackages(); }); test('can filter plugins with the --plugins argument', () async { - createFakePlugin('plugin1'); + createFakePlugin('plugin1', packagesDir); // Create a federated plugin by creating a directory under the packages // directory with several packages underneath. - final Directory federatedPlugin = - mockPackagesDir.childDirectory('my_plugin')..createSync(); + final Directory federatedPlugin = packagesDir.childDirectory('my_plugin') + ..createSync(); final Directory clientLibrary = federatedPlugin.childDirectory('my_plugin')..createSync(); createFakePubspec(clientLibrary); diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index cccff19de5e3..6d36031a2643 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -10,6 +10,7 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/publish_check_command.dart'; import 'package:http/http.dart' as http; @@ -21,14 +22,17 @@ import 'util.dart'; void main() { group('$PublishCheckProcessRunner tests', () { + FileSystem fileSystem; + Directory packagesDir; PublishCheckProcessRunner processRunner; CommandRunner runner; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = PublishCheckProcessRunner(); final PublishCheckCommand publishCheckCommand = - PublishCheckCommand(mockPackagesDir, processRunner: processRunner); + PublishCheckCommand(packagesDir, processRunner: processRunner); runner = CommandRunner( 'publish_check_command', @@ -37,13 +41,9 @@ void main() { runner.addCommand(publishCheckCommand); }); - tearDown(() { - mockPackagesDir.deleteSync(recursive: true); - }); - test('publish check all packages', () async { - final Directory plugin1Dir = createFakePlugin('a'); - final Directory plugin2Dir = createFakePlugin('b'); + final Directory plugin1Dir = createFakePlugin('a', packagesDir); + final Directory plugin2Dir = createFakePlugin('b', packagesDir); processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), @@ -68,7 +68,7 @@ void main() { }); test('fail on negative test', () async { - createFakePlugin('a'); + createFakePlugin('a', packagesDir); final MockProcess process = MockProcess(); process.stdoutController.close(); // ignore: unawaited_futures @@ -84,7 +84,7 @@ void main() { }); test('fail on bad pubspec', () async { - final Directory dir = createFakePlugin('c'); + final Directory dir = createFakePlugin('c', packagesDir); await dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); final MockProcess process = MockProcess(); @@ -95,7 +95,7 @@ void main() { }); test('pass on prerelease if --allow-pre-release flag is on', () async { - createFakePlugin('d'); + createFakePlugin('d', packagesDir); const String preReleaseOutput = 'Package has 1 warning.' 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'; @@ -114,7 +114,7 @@ void main() { }); test('fail on prerelease if --allow-pre-release flag is off', () async { - createFakePlugin('d'); + createFakePlugin('d', packagesDir); const String preReleaseOutput = 'Package has 1 warning.' 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'; @@ -132,7 +132,7 @@ void main() { }); test('Success message on stderr is not printed as an error', () async { - createFakePlugin('d'); + createFakePlugin('d', packagesDir); const String publishOutput = 'Package has 0 warnings.'; @@ -179,7 +179,7 @@ void main() { } return null; }); - final PublishCheckCommand command = PublishCheckCommand(mockPackagesDir, + final PublishCheckCommand command = PublishCheckCommand(packagesDir, processRunner: processRunner, httpClient: mockClient); runner = CommandRunner( @@ -189,9 +189,9 @@ void main() { runner.addCommand(command); final Directory plugin1Dir = - createFakePlugin('no_publish_a', includeVersion: true); + createFakePlugin('no_publish_a', packagesDir, includeVersion: true); final Directory plugin2Dir = - createFakePlugin('no_publish_b', includeVersion: true); + createFakePlugin('no_publish_b', packagesDir, includeVersion: true); createFakePubspec(plugin1Dir, name: 'no_publish_a', includeVersion: true, version: '0.1.0'); @@ -245,7 +245,7 @@ void main() { } return null; }); - final PublishCheckCommand command = PublishCheckCommand(mockPackagesDir, + final PublishCheckCommand command = PublishCheckCommand(packagesDir, processRunner: processRunner, httpClient: mockClient); runner = CommandRunner( @@ -255,9 +255,9 @@ void main() { runner.addCommand(command); final Directory plugin1Dir = - createFakePlugin('no_publish_a', includeVersion: true); + createFakePlugin('no_publish_a', packagesDir, includeVersion: true); final Directory plugin2Dir = - createFakePlugin('no_publish_b', includeVersion: true); + createFakePlugin('no_publish_b', packagesDir, includeVersion: true); createFakePubspec(plugin1Dir, name: 'no_publish_a', includeVersion: true, version: '0.1.0'); @@ -314,7 +314,7 @@ void main() { } return null; }); - final PublishCheckCommand command = PublishCheckCommand(mockPackagesDir, + final PublishCheckCommand command = PublishCheckCommand(packagesDir, processRunner: processRunner, httpClient: mockClient); runner = CommandRunner( @@ -324,9 +324,9 @@ void main() { runner.addCommand(command); final Directory plugin1Dir = - createFakePlugin('no_publish_a', includeVersion: true); + createFakePlugin('no_publish_a', packagesDir, includeVersion: true); final Directory plugin2Dir = - createFakePlugin('no_publish_b', includeVersion: true); + createFakePlugin('no_publish_b', packagesDir, includeVersion: true); createFakePubspec(plugin1Dir, name: 'no_publish_a', includeVersion: true, version: '0.1.0'); diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 1bf6ab7bbe74..570ceb234a84 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -22,9 +22,10 @@ import 'util.dart'; void main() { const String testPluginName = 'foo'; - final List printedMessages = []; + List printedMessages; - Directory parentDir; + Directory testRoot; + Directory packagesDir; Directory pluginDir; GitDir gitDir; TestProcessRunner processRunner; @@ -43,34 +44,34 @@ void main() { } setUp(() async { - parentDir = fileSystem.systemTempDirectory + testRoot = fileSystem.systemTempDirectory .createTempSync('publish_plugin_command_test-'); // The temp directory can have symbolic links, which won't match git output; // use a fully resolved version to avoid potential path comparison issues. - parentDir = fileSystem.directory(parentDir.resolveSymbolicLinksSync()); - initializeFakePackages(parentDir: parentDir); - pluginDir = createFakePlugin(testPluginName, - withSingleExample: false, packagesDirectory: parentDir); + testRoot = fileSystem.directory(testRoot.resolveSymbolicLinksSync()); + packagesDir = createPackagesDirectory(parentDir: testRoot); + pluginDir = + createFakePlugin(testPluginName, packagesDir, withSingleExample: false); assert(pluginDir != null && pluginDir.existsSync()); createFakePubspec(pluginDir, includeVersion: true); io.Process.runSync('git', ['init'], - workingDirectory: parentDir.path); - gitDir = await GitDir.fromExisting(parentDir.path); + workingDirectory: testRoot.path); + gitDir = await GitDir.fromExisting(testRoot.path); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Initial commit']); processRunner = TestProcessRunner(); mockStdin = MockStdin(); + printedMessages = []; commandRunner = CommandRunner('tester', '') - ..addCommand(PublishPluginCommand(parentDir, + ..addCommand(PublishPluginCommand(packagesDir, processRunner: processRunner, print: (Object message) => printedMessages.add(message.toString()), stdinput: mockStdin, - gitDir: await GitDir.fromExisting(parentDir.path))); + gitDir: gitDir)); }); tearDown(() { - parentDir.deleteSync(recursive: true); - printedMessages.clear(); + testRoot.deleteSync(recursive: true); }); group('Initial validation', () { @@ -109,7 +110,7 @@ void main() { expect( printedMessages, containsAllInOrder([ - 'There are files in the package directory that haven\'t been saved in git. Refusing to publish these files:\n\n?? foo/tmp\n\nIf the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.', + 'There are files in the package directory that haven\'t been saved in git. Refusing to publish these files:\n\n?? packages/foo/tmp\n\nIf the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.', 'Failed, see above for details.', ])); }); @@ -140,8 +141,8 @@ void main() { test('can publish non-flutter package', () async { createFakePubspec(pluginDir, includeVersion: true, isFlutter: false); io.Process.runSync('git', ['init'], - workingDirectory: parentDir.path); - gitDir = await GitDir.fromExisting(parentDir.path); + workingDirectory: testRoot.path); + gitDir = await GitDir.fromExisting(testRoot.path); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Initial commit']); // Immediately return 0 when running `pub publish`. @@ -420,21 +421,19 @@ void main() { group('Auto release (all-changed flag)', () { setUp(() async { io.Process.runSync('git', ['init'], - workingDirectory: parentDir.path); - gitDir = await GitDir.fromExisting(parentDir.path); + workingDirectory: testRoot.path); + gitDir = await GitDir.fromExisting(testRoot.path); await gitDir.runCommand( ['remote', 'add', 'upstream', 'http://localhost:8000']); }); test('can release newly created plugins', () async { // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -475,8 +474,8 @@ void main() { test('can release newly created plugins, while there are existing plugins', () async { // Prepare an exiting plugin and tag it - final Directory pluginDir0 = createFakePlugin('plugin0', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir0 = + createFakePlugin('plugin0', packagesDir, withSingleExample: true); createFakePubspec(pluginDir0, name: 'plugin0', includeVersion: true, @@ -492,13 +491,11 @@ void main() { processRunner.pushTagsArgs.clear(); // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -536,13 +533,11 @@ void main() { test('can release newly created plugins, dry run', () async { // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -585,13 +580,11 @@ void main() { test('version change triggers releases.', () async { // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -676,13 +669,11 @@ void main() { 'delete package will not trigger publish but exit the command successfully.', () async { // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -764,13 +755,11 @@ void main() { 'versions revert do not trigger releases. Also prints out warning message.', () async { // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -846,13 +835,11 @@ void main() { test('No version change does not release any plugins', () async { // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', - withSingleExample: true, packagesDirectory: parentDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, withSingleExample: true); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', - withSingleExample: true, - parentDirectoryName: 'plugin2', - packagesDirectory: parentDir); + final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, + withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, name: 'plugin1', includeVersion: true, @@ -865,8 +852,8 @@ void main() { version: '0.0.1'); io.Process.runSync('git', ['init'], - workingDirectory: parentDir.path); - gitDir = await GitDir.fromExisting(parentDir.path); + workingDirectory: testRoot.path); + gitDir = await GitDir.fromExisting(testRoot.path); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index e1b1d364978f..576060d23a9d 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -21,7 +21,7 @@ void main() { setUp(() { fileSystem = MemoryFileSystem(); packagesDir = fileSystem.currentDirectory.childDirectory('packages'); - initializeFakePackages(parentDir: packagesDir.parent); + createPackagesDirectory(parentDir: packagesDir.parent); processRunner = RecordingProcessRunner(); final PubspecCheckCommand command = PubspecCheckCommand(packagesDir, processRunner: processRunner); @@ -88,8 +88,8 @@ dev_dependencies: } test('passes for a plugin following conventions', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -114,8 +114,8 @@ ${devDependenciesSection()} }); test('passes for a Flutter package following conventions', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin')} @@ -163,8 +163,8 @@ ${dependenciesSection()} }); test('fails when homepage is included', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeHomepage: true)} @@ -184,8 +184,8 @@ ${devDependenciesSection()} }); test('fails when repository is missing', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeRepository: false)} @@ -205,8 +205,8 @@ ${devDependenciesSection()} }); test('fails when homepage is given instead of repository', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeHomepage: true, includeRepository: false)} @@ -226,8 +226,8 @@ ${devDependenciesSection()} }); test('fails when issue tracker is missing', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeIssueTracker: false)} @@ -247,8 +247,8 @@ ${devDependenciesSection()} }); test('fails when environment section is out of order', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -268,8 +268,8 @@ ${environmentSection()} }); test('fails when flutter section is out of order', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -289,8 +289,8 @@ ${devDependenciesSection()} }); test('fails when dependencies section is out of order', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -310,8 +310,8 @@ ${dependenciesSection()} }); test('fails when devDependencies section is out of order', () async { - final Directory pluginDirectory = createFakePlugin('plugin', - withSingleExample: true, packagesDirectory: packagesDir); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, withSingleExample: true); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 61c06e0af1b7..5cbbdf5b8d46 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/test_command.dart'; import 'package:test/test.dart'; @@ -11,32 +12,31 @@ import 'util.dart'; void main() { group('$TestCommand', () { + late FileSystem fileSystem; + late Directory packagesDir; late CommandRunner runner; - final RecordingProcessRunner processRunner = RecordingProcessRunner(); + late RecordingProcessRunner processRunner; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); final TestCommand command = - TestCommand(mockPackagesDir, processRunner: processRunner); + TestCommand(packagesDir, processRunner: processRunner); runner = CommandRunner('test_test', 'Test for $TestCommand'); runner.addCommand(command); }); - tearDown(() { - cleanupPackages(); - processRunner.recordedCalls.clear(); - }); - test('runs flutter test on each plugin', () async { - final Directory plugin1Dir = - createFakePlugin('plugin1', withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory plugin2Dir = - createFakePlugin('plugin2', withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, + withExtraFiles: >[ + ['test', 'empty_test.dart'], + ]); + final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, + withExtraFiles: >[ + ['test', 'empty_test.dart'], + ]); await runner.run(['test']); @@ -49,16 +49,14 @@ void main() { 'flutter', const ['test', '--color'], plugin2Dir.path), ]), ); - - cleanupPackages(); }); test('skips testing plugins without test directory', () async { - createFakePlugin('plugin1'); - final Directory plugin2Dir = - createFakePlugin('plugin2', withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); + createFakePlugin('plugin1', packagesDir); + final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, + withExtraFiles: >[ + ['test', 'empty_test.dart'], + ]); await runner.run(['test']); @@ -69,17 +67,15 @@ void main() { 'flutter', const ['test', '--color'], plugin2Dir.path), ]), ); - - cleanupPackages(); }); test('runs pub run test on non-Flutter packages', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, isFlutter: true, withExtraFiles: >[ ['test', 'empty_test.dart'], ]); - final Directory plugin2Dir = createFakePlugin('plugin2', + final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, isFlutter: false, withExtraFiles: >[ ['test', 'empty_test.dart'], @@ -101,13 +97,12 @@ void main() { plugin2Dir.path), ]), ); - - cleanupPackages(); }); test('runs on Chrome for web plugins', () async { final Directory pluginDir = createFakePlugin( 'plugin', + packagesDir, withExtraFiles: >[ ['test', 'empty_test.dart'], ], @@ -129,12 +124,12 @@ void main() { }); test('enable-experiment flag', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, isFlutter: true, withExtraFiles: >[ ['test', 'empty_test.dart'], ]); - final Directory plugin2Dir = createFakePlugin('plugin2', + final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, isFlutter: false, withExtraFiles: >[ ['test', 'empty_test.dart'], @@ -156,8 +151,6 @@ void main() { plugin2Dir.path), ]), ); - - cleanupPackages(); }); }); } diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 7d4278f68cc9..a0a316f95dfc 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -11,32 +11,29 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:meta/meta.dart'; -import 'package:platform/platform.dart'; import 'package:quiver/collection.dart'; -// TODO(stuartmorgan): Eliminate this in favor of setting up a clean filesystem -// for each test, to eliminate the chance of files from one test interfering -// with another test. -FileSystem mockFileSystem = MemoryFileSystem( - style: const LocalPlatform().isWindows - ? FileSystemStyle.windows - : FileSystemStyle.posix); -late Directory mockPackagesDir; - -/// Creates a mock packages directory in the mock file system. +/// Creates a packages directory in the given location. /// -/// If [parentDir] is set the mock packages dir will be creates as a child of -/// it. If not [mockFileSystem] will be used instead. -void initializeFakePackages({Directory? parentDir}) { - mockPackagesDir = - (parentDir ?? mockFileSystem.currentDirectory).childDirectory('packages'); - mockPackagesDir.createSync(); +/// If [parentDir] is set the packages directory will be created there, +/// otherwise [fileSystem] must be provided and it will be created an arbitrary +/// location in that filesystem. +Directory createPackagesDirectory( + {Directory? parentDir, FileSystem? fileSystem}) { + assert(parentDir != null || fileSystem != null, + 'One of parentDir or fileSystem must be provided'); + assert(fileSystem == null || fileSystem is MemoryFileSystem, + 'If using a real filesystem, parentDir must be provided'); + final Directory packagesDir = + (parentDir ?? fileSystem!.currentDirectory).childDirectory('packages'); + packagesDir.createSync(); + return packagesDir; } -/// Creates a plugin package with the given [name] in [packagesDirectory], -/// defaulting to [mockPackagesDir]. +/// Creates a plugin package with the given [name] in [packagesDirectory]. Directory createFakePlugin( - String name, { + String name, + Directory packagesDirectory, { bool withSingleExample = false, List withExamples = const [], List> withExtraFiles = const >[], @@ -51,12 +48,11 @@ Directory createFakePlugin( bool includeVersion = false, String version = '0.0.1', String parentDirectoryName = '', - Directory? packagesDirectory, }) { assert(!(withSingleExample && withExamples.isNotEmpty), 'cannot pass withSingleExample and withExamples simultaneously'); - Directory parentDirectory = packagesDirectory ?? mockPackagesDir; + Directory parentDirectory = packagesDirectory; if (parentDirectoryName != '') { parentDirectory = parentDirectory.childDirectory(parentDirectoryName); } @@ -198,13 +194,6 @@ publish_to: $publishTo # Hardcoded safeguard to prevent this from somehow being parent.childFile('pubspec.yaml').writeAsStringSync(yaml); } -/// Cleans up the mock packages directory, making it an empty directory again. -void cleanupPackages() { - mockPackagesDir.listSync().forEach((FileSystemEntity entity) { - entity.deleteSync(recursive: true); - }); -} - typedef _ErrorHandler = void Function(Error error); /// Run the command [runner] with the given [args] and return diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index 600d9a08c3fe..ec76ceba8e77 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -10,6 +10,7 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/version_check_command.dart'; import 'package:git/git.dart'; @@ -55,6 +56,8 @@ String _redColorString(String string) { void main() { const String indentation = ' '; group('$VersionCheckCommand', () { + FileSystem fileSystem; + Directory packagesDir; CommandRunner runner; RecordingProcessRunner processRunner; List> gitDirCommands; @@ -63,6 +66,8 @@ void main() { MockGitDir gitDir; setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); gitDirCommands = >[]; gitDiffResponse = ''; gitShowResponses = {}; @@ -86,9 +91,8 @@ void main() { } return Future.value(mockProcessResult); }); - initializeFakePackages(); processRunner = RecordingProcessRunner(); - final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, + final VersionCheckCommand command = VersionCheckCommand(packagesDir, processRunner: processRunner, gitDir: gitDir); runner = CommandRunner( @@ -96,12 +100,9 @@ void main() { runner.addCommand(command); }); - tearDown(() { - cleanupPackages(); - }); - test('allows valid version', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', @@ -127,7 +128,8 @@ void main() { }); test('denies invalid version', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 0.0.1', @@ -151,7 +153,8 @@ void main() { }); test('allows valid version without explicit base-sha', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 1.0.0', @@ -169,7 +172,8 @@ void main() { }); test('allows valid version for new package.', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'HEAD:packages/plugin/pubspec.yaml': 'version: 1.0.0', @@ -187,7 +191,8 @@ void main() { }); test('allows likely reverts.', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', @@ -205,7 +210,8 @@ void main() { }); test('denies lower version that could not be a simple revert', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', @@ -221,7 +227,8 @@ void main() { }); test('denies invalid version without explicit base-sha', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.0.1', @@ -237,7 +244,7 @@ void main() { }); test('gracefully handles missing pubspec.yaml', () async { - final Directory pluginDir = createFakePlugin('plugin', + final Directory pluginDir = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; pluginDir.childFile('pubspec.yaml').deleteSync(); @@ -259,7 +266,7 @@ void main() { }); test('allows minor changes to platform interfaces', () async { - createFakePlugin('plugin_platform_interface', + createFakePlugin('plugin_platform_interface', packagesDir, includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin_platform_interface/pubspec.yaml'; gitShowResponses = { @@ -293,7 +300,7 @@ void main() { }); test('disallows breaking changes to platform interfaces', () async { - createFakePlugin('plugin_platform_interface', + createFakePlugin('plugin_platform_interface', packagesDir, includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin_platform_interface/pubspec.yaml'; gitShowResponses = { @@ -326,10 +333,8 @@ void main() { test('Allow empty lines in front of the first version in CHANGELOG', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.1'); @@ -355,10 +360,8 @@ void main() { }); test('Throws if versions in changelog and pubspec do not match', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.1'); @@ -392,10 +395,8 @@ The first version listed in CHANGELOG.md is 1.0.2. }); test('Success if CHANGELOG and pubspec versions match', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.1'); @@ -420,10 +421,8 @@ The first version listed in CHANGELOG.md is 1.0.2. test( 'Fail if pubspec version only matches an older version listed in CHANGELOG', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.0'); @@ -464,10 +463,8 @@ The first version listed in CHANGELOG.md is 1.0.1. test('Allow NEXT as a placeholder for gathering CHANGELOG entries', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.0'); @@ -495,10 +492,8 @@ The first version listed in CHANGELOG.md is 1.0.1. test('Fail if NEXT is left in the CHANGELOG when adding a version bump', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.1'); @@ -541,10 +536,8 @@ into the new version's release notes. }); test('Fail if the version changes without replacing NEXT', () async { - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); - - final Directory pluginDirectory = - mockPackagesDir.childDirectory('plugin'); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); createFakePubspec(pluginDirectory, isFlutter: true, includeVersion: true, version: '1.0.1'); @@ -596,14 +589,15 @@ The first version listed in CHANGELOG.md is 1.0.0. final MockClient mockClient = MockClient((http.Request request) async { return http.Response(json.encode(httpResponse), 200); }); - final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, + final VersionCheckCommand command = VersionCheckCommand(packagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', @@ -632,14 +626,15 @@ The first version listed in CHANGELOG.md is 1.0.0. final MockClient mockClient = MockClient((http.Request request) async { return http.Response(json.encode(httpResponse), 200); }); - final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, + final VersionCheckCommand command = VersionCheckCommand(packagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', @@ -676,14 +671,15 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N final MockClient mockClient = MockClient((http.Request request) async { return http.Response('xx', 400); }); - final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, + final VersionCheckCommand command = VersionCheckCommand(packagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', @@ -719,14 +715,15 @@ ${indentation}HTTP response: xx final MockClient mockClient = MockClient((http.Request request) async { return http.Response('xx', 404); }); - final VersionCheckCommand command = VersionCheckCommand(mockPackagesDir, + final VersionCheckCommand command = VersionCheckCommand(packagesDir, processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); runner = CommandRunner( 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + createFakePlugin('plugin', packagesDir, + includeChangeLog: true, includeVersion: true); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 0b25a5b015c1..174dba1d5a43 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/xctest_command.dart'; import 'package:test/test.dart'; @@ -85,31 +86,31 @@ void main() { const String _kSkip = '--skip'; group('test xctest_command', () { + FileSystem fileSystem; + Directory packagesDir; CommandRunner runner; RecordingProcessRunner processRunner; setUp(() { - initializeFakePackages(); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); final XCTestCommand command = - XCTestCommand(mockPackagesDir, processRunner: processRunner); + XCTestCommand(packagesDir, processRunner: processRunner); runner = CommandRunner('xctest_command', 'Test for xctest_command'); runner.addCommand(command); - cleanupPackages(); }); test('skip if ios is not supported', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isIosPlugin: false); - final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePubspec(pluginDirectory.childDirectory('example'), + isFlutter: true); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -118,27 +119,27 @@ void main() { runner, ['xctest', _kDestination, 'foo_destination']); expect(output, contains('iOS is not supported by this plugin.')); expect(processRunner.recordedCalls, orderedEquals([])); - - cleanupPackages(); }); test('running with correct destination, skip 1 plugin', () async { - createFakePlugin('plugin1', - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); - createFakePlugin('plugin2', - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); + final Directory pluginDirectory1 = + createFakePlugin('plugin1', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: true); + final Directory pluginDirectory2 = + createFakePlugin('plugin2', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: true); final Directory pluginExampleDirectory1 = - mockPackagesDir.childDirectory('plugin1').childDirectory('example'); + pluginDirectory1.childDirectory('example'); createFakePubspec(pluginExampleDirectory1, isFlutter: true); final Directory pluginExampleDirectory2 = - mockPackagesDir.childDirectory('plugin2').childDirectory('example'); + pluginDirectory2.childDirectory('example'); createFakePubspec(pluginExampleDirectory2, isFlutter: true); final MockProcess mockProcess = MockProcess(); @@ -178,20 +179,18 @@ void main() { ], pluginExampleDirectory2.path), ])); - - cleanupPackages(); }); test('Not specifying --ios-destination assigns an available simulator', () async { - createFakePlugin('plugin', + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, withExtraFiles: >[ ['example', 'test'], ], isIosPlugin: true); final Directory pluginExampleDirectory = - mockPackagesDir.childDirectory('plugin').childDirectory('example'); + pluginDirectory.childDirectory('example'); createFakePubspec(pluginExampleDirectory, isFlutter: true); @@ -234,8 +233,6 @@ void main() { ], pluginExampleDirectory.path), ])); - - cleanupPackages(); }); }); } From 04f8ef7618f12116c0cf731639f2071837ce56f8 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 7 Jun 2021 11:29:07 -0700 Subject: [PATCH 022/364] [flutter_plugin_tools] Remove xctest's --skip (#4022) --- script/tool/CHANGELOG.md | 4 ++++ script/tool/README.md | 4 ++-- script/tool/lib/src/xctest_command.dart | 10 ---------- script/tool/pubspec.yaml | 2 +- script/tool/test/xctest_command_test.dart | 7 +++---- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 6250e2a7273b..bd0875c2dbb0 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.0 + +- Remove `xctest`'s `--skip`, which is redundant with `--ignore`. + ## 0.1.4 - Add a `pubspec-check` command diff --git a/script/tool/README.md b/script/tool/README.md index 3e9484c3ff0c..8ff33a807b81 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -46,7 +46,7 @@ dart pub global run flutter_plugin_tools ## Commands Run with `--help` for a full list of commands and arguments, but the -following shows a number of common commands. +following shows a number of common commands being run for a specific plugin. All examples assume running from source; see above for running the published version instead. @@ -79,7 +79,7 @@ dart run ./script/tool/lib/src/main.dart test --plugins plugin_name ```sh cd -dart run ./script/tool/lib/src/main.dart xctest --target RunnerUITests --skip +dart run ./script/tool/lib/src/main.dart xctest --plugins plugin_name ``` ### Publish a Release diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index e41164e3ed8d..c8775307bf22 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -14,7 +14,6 @@ import 'package:path/path.dart' as p; import 'common.dart'; const String _kiOSDestination = 'ios-destination'; -const String _kSkip = 'skip'; const String _kXcodeBuildCommand = 'xcodebuild'; const String _kXCRunCommand = 'xcrun'; const String _kFoundNoSimulatorsMessage = @@ -36,8 +35,6 @@ class XCTestCommand extends PluginCommand { 'this is passed to the `-destination` argument in xcodebuild command.\n' 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.', ); - argParser.addMultiOption(_kSkip, - help: 'Plugins to skip while running this command. \n'); } @override @@ -59,8 +56,6 @@ class XCTestCommand extends PluginCommand { destination = 'id=$simulatorId'; } - final List skipped = getStringListArg(_kSkip); - final List failingPackages = []; await for (final Directory plugin in getPlugins()) { // Start running for package. @@ -72,11 +67,6 @@ class XCTestCommand extends PluginCommand { print('\n\n'); continue; } - if (skipped.contains(packageName)) { - print('$packageName was skipped with the --skip flag.'); - print('\n\n'); - continue; - } for (final Directory example in getExamplesForPlugin(plugin)) { // Running tests and static analyzer. print('Running tests and analyzer for $packageName ...'); diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index ab422daf8ed5..5d2200abcdb0 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.1.4 +version: 0.2.0 dependencies: args: ^2.1.0 diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 174dba1d5a43..ede231134774 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -83,7 +83,6 @@ final Map _kDeviceListMap = { void main() { const String _kDestination = '--ios-destination'; - const String _kSkip = '--skip'; group('test xctest_command', () { FileSystem fileSystem; @@ -121,7 +120,7 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('running with correct destination, skip 1 plugin', () async { + test('running with correct destination, exclude 1 plugin', () async { final Directory pluginDirectory1 = createFakePlugin('plugin1', packagesDir, withExtraFiles: >[ @@ -151,11 +150,11 @@ void main() { 'xctest', _kDestination, 'foo_destination', - _kSkip, + '--exclude', 'plugin1' ]); - expect(output, contains('plugin1 was skipped with the --skip flag.')); + expect(output, isNot(contains('Successfully ran xctest for plugin1'))); expect(output, contains('Successfully ran xctest for plugin2')); expect( From 986eb8eba36bd8c679697a6333d7161450e0846c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 7 Jun 2021 11:44:05 -0700 Subject: [PATCH 023/364] [flutter_plugin_tools] Migrate xctest command to NNBD (#4024) --- script/tool/lib/src/xctest_command.dart | 40 ++++++++++++++--------- script/tool/test/mocks.dart | 3 ++ script/tool/test/xctest_command_test.dart | 13 ++++---- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index c8775307bf22..1b157ce1ae2d 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -48,7 +46,7 @@ class XCTestCommand extends PluginCommand { Future run() async { String destination = getStringArg(_kiOSDestination); if (destination.isEmpty) { - final String simulatorId = await _findAvailableIphoneSimulator(); + final String? simulatorId = await _findAvailableIphoneSimulator(); if (simulatorId == null) { print(_kFoundNoSimulatorsMessage); throw ToolExit(1); @@ -119,7 +117,7 @@ class XCTestCommand extends PluginCommand { workingDir: example, exitOnError: false); } - Future _findAvailableIphoneSimulator() async { + Future _findAvailableIphoneSimulator() async { // Find the first available destination if not specified. final List findSimulatorsArguments = [ 'simctl', @@ -143,30 +141,40 @@ class XCTestCommand extends PluginCommand { final List> runtimes = (simulatorListJson['runtimes'] as List) .cast>(); - final Map devices = - simulatorListJson['devices'] as Map; + final Map devices = + (simulatorListJson['devices'] as Map) + .cast(); if (runtimes.isEmpty || devices.isEmpty) { return null; } - String id; + String? id; // Looking for runtimes, trying to find one with highest OS version. - for (final Map runtimeMap in runtimes.reversed) { - if (!(runtimeMap['name'] as String).contains('iOS')) { + for (final Map rawRuntimeMap in runtimes.reversed) { + final Map runtimeMap = + rawRuntimeMap.cast(); + if ((runtimeMap['name'] as String?)?.contains('iOS') != true) { + continue; + } + final String? runtimeID = runtimeMap['identifier'] as String?; + if (runtimeID == null) { continue; } - final String runtimeID = runtimeMap['identifier'] as String; - final List> devicesForRuntime = - (devices[runtimeID] as List).cast>(); - if (devicesForRuntime.isEmpty) { + final List>? devicesForRuntime = + (devices[runtimeID] as List?)?.cast>(); + if (devicesForRuntime == null || devicesForRuntime.isEmpty) { continue; } // Looking for runtimes, trying to find latest version of device. - for (final Map device in devicesForRuntime.reversed) { + for (final Map rawDevice in devicesForRuntime.reversed) { + final Map device = rawDevice.cast(); if (device['availabilityError'] != null || - (device['isAvailable'] as bool == false)) { + (device['isAvailable'] as bool?) == false) { + continue; + } + id = device['udid'] as String?; + if (id == null) { continue; } - id = device['udid'] as String; print('device selected: $device'); return id; } diff --git a/script/tool/test/mocks.dart b/script/tool/test/mocks.dart index b984247af9a8..66267ec58255 100644 --- a/script/tool/test/mocks.dart +++ b/script/tool/test/mocks.dart @@ -16,6 +16,9 @@ class MockProcess extends Mock implements io.Process { StreamController>(); final MockIOSink stdinMock = MockIOSink(); + @override + int get pid => 99; + @override Future get exitCode => exitCodeCompleter.future; diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index ede231134774..ffe9bf4267ae 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:convert'; import 'package:args/command_runner.dart'; @@ -15,6 +13,9 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'util.dart'; +// Note: This uses `dynamic` deliberately, and should not be updated to Object, +// in order to ensure that the code correctly handles this return type from +// JSON decoding. final Map _kDeviceListMap = { 'runtimes': >[ { @@ -85,10 +86,10 @@ void main() { const String _kDestination = '--ios-destination'; group('test xctest_command', () { - FileSystem fileSystem; - Directory packagesDir; - CommandRunner runner; - RecordingProcessRunner processRunner; + late FileSystem fileSystem; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); From 27b76d877924917e34ce112863bd452330147467 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 7 Jun 2021 22:38:03 +0200 Subject: [PATCH 024/364] Avoid nullable Future.value (#4027) --- .../test/store_kit_wrappers/sk_methodchannel_apis_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index 98f6dac9598f..e71279edca4f 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -174,7 +174,7 @@ class FakeIOSPlatform { assert(productIDS is List, 'invalid argument type'); startProductRequestParam = call.arguments; if (getProductRequestFailTest) { - return Future>.value(null); + return Future.value(null); } return Future>.value( buildProductResponseMap(dummyProductResponseWrapper)); From 642a4831e65190011f18180432cee1f03967b2cf Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Mon, 7 Jun 2021 13:49:04 -0700 Subject: [PATCH 025/364] Ignore upcoming unnecessary_imports analysis (#4023) --- analysis_options.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/analysis_options.yaml b/analysis_options.yaml index 5d4fbadd3dec..1359851bd3a7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -49,6 +49,8 @@ analyzer: # Allow null checks for as long as mixed mode is officially supported. unnecessary_null_comparison: false always_require_non_null_named_parameters: false # not needed with nnbd + # TODO(https://github.com/flutter/flutter/issues/74381) + unnecessary_imports: ignore exclude: # Ignore generated files - '**/*.g.dart' From 70fcc6554665ad94efde6f56c550c2a55560de4e Mon Sep 17 00:00:00 2001 From: Yusuf <82844127+ydag@users.noreply.github.com> Date: Tue, 8 Jun 2021 09:05:44 +0200 Subject: [PATCH 026/364] [image_picker] Fix rotation when camera is a source (#4019) * Fix isMetadataAvailable bool * Add unit test * Update CHANGELOG and version --- packages/image_picker/image_picker/CHANGELOG.md | 4 ++++ .../example/ios/RunnerTests/ImageUtilTests.m | 10 ++++++++++ .../image_picker/ios/Classes/FLTImagePickerPlugin.m | 2 +- packages/image_picker/image_picker/pubspec.yaml | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 703b00bf92a7..e83ab7234a6e 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.0+2 + +* Fix a rotation problem where when camera is chosen as a source and additional parameters are added. + ## 0.8.0+1 * Removed redundant request for camera permissions. diff --git a/packages/image_picker/image_picker/example/ios/RunnerTests/ImageUtilTests.m b/packages/image_picker/image_picker/example/ios/RunnerTests/ImageUtilTests.m index 54a691d70963..b793d6e1f3e0 100644 --- a/packages/image_picker/image_picker/example/ios/RunnerTests/ImageUtilTests.m +++ b/packages/image_picker/image_picker/example/ios/RunnerTests/ImageUtilTests.m @@ -34,6 +34,16 @@ - (void)testScaledImage_ShouldBeScaledWithNoMetadata { XCTAssertEqual(newImage.size.height, 2); } +- (void)testScaledImage_ShouldBeCorrectRotation { + UIImage *image = [UIImage imageWithData:ImagePickerTestImages.JPGTestData]; + UIImage *newImage = [FLTImagePickerImageUtil scaledImage:image + maxWidth:@3 + maxHeight:@2 + isMetadataAvailable:YES]; + + XCTAssertEqual(newImage.imageOrientation, UIImageOrientationUp); +} + - (void)testScaledGIFImage_ShouldBeScaled { // gif image that frame size is 3 and the duration is 1 second. GIFInfo *info = [FLTImagePickerImageUtil scaledGIFImage:ImagePickerTestImages.GIFTestData diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m index c4ea34a8128f..e3df6413e9a8 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m @@ -460,7 +460,7 @@ - (void)imagePickerController:(UIImagePickerController *)picker image = [FLTImagePickerImageUtil scaledImage:image maxWidth:maxWidth maxHeight:maxHeight - isMetadataAvailable:originalAsset != nil]; + isMetadataAvailable:YES]; } if (!originalAsset) { diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index c24fdd01fb1c..4ca29b4d33d1 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.0+1 +version: 0.8.0+2 environment: sdk: ">=2.12.0 <3.0.0" From 7ebdbcb81397f10eee2b93ddecf586620715e65f Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Tue, 8 Jun 2021 10:24:04 +0200 Subject: [PATCH 027/364] [image_picker] Reverted removal of camera permission request (#4021) --- .../image_picker/image_picker/CHANGELOG.md | 4 + .../imagepicker/ImagePickerDelegate.java | 103 ++++++++++++++- .../imagepicker/ImagePickerPlugin.java | 3 + .../imagepicker/ImagePickerDelegateTest.java | 123 ++++++++++++++---- .../image_picker/image_picker/pubspec.yaml | 2 +- 5 files changed, 205 insertions(+), 30 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index e83ab7234a6e..b913bbf29758 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.0+3 + +* Readded request for camera permissions. + ## 0.8.0+2 * Fix a rotation problem where when camera is chosen as a source and additional parameters are added. diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index 41df851d1d00..c934b54a1f8e 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -4,6 +4,7 @@ package io.flutter.plugins.imagepicker; +import android.Manifest; import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; @@ -14,6 +15,7 @@ import android.os.Build; import android.provider.MediaStore; import androidx.annotation.VisibleForTesting; +import androidx.core.app.ActivityCompat; import androidx.core.content.FileProvider; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -40,7 +42,19 @@ enum CameraDevice { * means that the chooseImageFromGallery() or takeImageWithCamera() method was called at least * twice. In this case, stop executing and finish with an error. * - *

2. Launch the gallery or camera for picking the image, depending on whether + *

2. Check that a required runtime permission has been granted. The takeImageWithCamera() method + * checks that {@link Manifest.permission#CAMERA} has been granted. + * + *

The permission check can end up in two different outcomes: + * + *

A) If the permission has already been granted, continue with picking the image from gallery or + * camera. + * + *

B) If the permission hasn't already been granted, ask for the permission from the user. If the + * user grants the permission, proceed with step #3. If the user denies the permission, stop doing + * anything else and finish with a null result. + * + *

3. Launch the gallery or camera for picking the image, depending on whether * chooseImageFromGallery() or takeImageWithCamera() was called. * *

This can end up in three different outcomes: @@ -55,11 +69,15 @@ enum CameraDevice { * *

C) User cancels picking an image. Finish with null result. */ -public class ImagePickerDelegate implements PluginRegistry.ActivityResultListener { +public class ImagePickerDelegate + implements PluginRegistry.ActivityResultListener, + PluginRegistry.RequestPermissionsResultListener { @VisibleForTesting static final int REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY = 2342; @VisibleForTesting static final int REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA = 2343; + @VisibleForTesting static final int REQUEST_CAMERA_IMAGE_PERMISSION = 2345; @VisibleForTesting static final int REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY = 2352; @VisibleForTesting static final int REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA = 2353; + @VisibleForTesting static final int REQUEST_CAMERA_VIDEO_PERMISSION = 2355; @VisibleForTesting final String fileProviderName; @@ -67,11 +85,20 @@ public class ImagePickerDelegate implements PluginRegistry.ActivityResultListene @VisibleForTesting final File externalFilesDirectory; private final ImageResizer imageResizer; private final ImagePickerCache cache; + private final PermissionManager permissionManager; private final IntentResolver intentResolver; private final FileUriResolver fileUriResolver; private final FileUtils fileUtils; private CameraDevice cameraDevice; + interface PermissionManager { + boolean isPermissionGranted(String permissionName); + + void askForPermission(String permissionName, int requestCode); + + boolean needRequestCameraPermission(); + } + interface IntentResolver { boolean resolveActivity(Intent intent); } @@ -102,6 +129,23 @@ public ImagePickerDelegate( null, null, cache, + new PermissionManager() { + @Override + public boolean isPermissionGranted(String permissionName) { + return ActivityCompat.checkSelfPermission(activity, permissionName) + == PackageManager.PERMISSION_GRANTED; + } + + @Override + public void askForPermission(String permissionName, int requestCode) { + ActivityCompat.requestPermissions(activity, new String[] {permissionName}, requestCode); + } + + @Override + public boolean needRequestCameraPermission() { + return ImagePickerUtils.needRequestCameraPermission(activity); + } + }, new IntentResolver() { @Override public boolean resolveActivity(Intent intent) { @@ -143,6 +187,7 @@ public void onScanCompleted(String path, Uri uri) { final MethodChannel.Result result, final MethodCall methodCall, final ImagePickerCache cache, + final PermissionManager permissionManager, final IntentResolver intentResolver, final FileUriResolver fileUriResolver, final FileUtils fileUtils) { @@ -152,6 +197,7 @@ public void onScanCompleted(String path, Uri uri) { this.fileProviderName = activity.getPackageName() + ".flutter.image_provider"; this.pendingResult = result; this.methodCall = methodCall; + this.permissionManager = permissionManager; this.intentResolver = intentResolver; this.fileUriResolver = fileUriResolver; this.fileUtils = fileUtils; @@ -223,6 +269,13 @@ public void takeVideoWithCamera(MethodCall methodCall, MethodChannel.Result resu return; } + if (needRequestCameraPermission() + && !permissionManager.isPermissionGranted(Manifest.permission.CAMERA)) { + permissionManager.askForPermission( + Manifest.permission.CAMERA, REQUEST_CAMERA_VIDEO_PERMISSION); + return; + } + launchTakeVideoWithCameraIntent(); } @@ -275,9 +328,22 @@ public void takeImageWithCamera(MethodCall methodCall, MethodChannel.Result resu return; } + if (needRequestCameraPermission() + && !permissionManager.isPermissionGranted(Manifest.permission.CAMERA)) { + permissionManager.askForPermission( + Manifest.permission.CAMERA, REQUEST_CAMERA_IMAGE_PERMISSION); + return; + } launchTakeImageWithCameraIntent(); } + private boolean needRequestCameraPermission() { + if (permissionManager == null) { + return false; + } + return permissionManager.needRequestCameraPermission(); + } + private void launchTakeImageWithCameraIntent() { Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (cameraDevice == CameraDevice.FRONT) { @@ -335,6 +401,39 @@ private void grantUriPermissions(Intent intent, Uri imageUri) { } } + @Override + public boolean onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + boolean permissionGranted = + grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED; + + switch (requestCode) { + case REQUEST_CAMERA_IMAGE_PERMISSION: + if (permissionGranted) { + launchTakeImageWithCameraIntent(); + } + break; + case REQUEST_CAMERA_VIDEO_PERMISSION: + if (permissionGranted) { + launchTakeVideoWithCameraIntent(); + } + break; + default: + return false; + } + + if (!permissionGranted) { + switch (requestCode) { + case REQUEST_CAMERA_IMAGE_PERMISSION: + case REQUEST_CAMERA_VIDEO_PERMISSION: + finishWithError("camera_access_denied", "The user did not allow camera access."); + break; + } + } + + return true; + } + @Override public boolean onActivityResult(int requestCode, int resultCode, Intent data) { switch (requestCode) { diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java index b4e7e8a06ce3..bffc903b531e 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java @@ -192,9 +192,11 @@ private void setup( // V1 embedding setup for activity listeners. application.registerActivityLifecycleCallbacks(observer); registrar.addActivityResultListener(delegate); + registrar.addRequestPermissionsResultListener(delegate); } else { // V2 embedding setup for activity listeners. activityBinding.addActivityResultListener(delegate); + activityBinding.addRequestPermissionsResultListener(delegate); lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(activityBinding); lifecycle.addObserver(observer); } @@ -202,6 +204,7 @@ private void setup( private void tearDown() { activityBinding.removeActivityResultListener(delegate); + activityBinding.removeRequestPermissionsResultListener(delegate); activityBinding = null; lifecycle.removeObserver(observer); lifecycle = null; diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index 5b66814de761..da53b10b50f5 100644 --- a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import android.Manifest; import android.app.Activity; import android.content.Context; import android.content.Intent; @@ -39,6 +40,7 @@ public class ImagePickerDelegateTest { @Mock ImageResizer mockImageResizer; @Mock MethodCall mockMethodCall; @Mock MethodChannel.Result mockResult; + @Mock ImagePickerDelegate.PermissionManager mockPermissionManager; @Mock ImagePickerDelegate.IntentResolver mockIntentResolver; @Mock FileUtils mockFileUtils; @Mock Intent mockIntent; @@ -102,7 +104,11 @@ public void chooseImageFromGallery_WhenPendingResultExists_FinishesWithAlreadyAc } @Test - public void chooseImageFromGallery_LaunchesChooseFromGalleryIntent() { + public void + chooseImageFromGallery_WhenHasExternalStoragePermission_LaunchesChooseFromGalleryIntent() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.READ_EXTERNAL_STORAGE)) + .thenReturn(true); + ImagePickerDelegate delegate = createDelegate(); delegate.chooseImageFromGallery(mockMethodCall, mockResult); @@ -122,30 +128,49 @@ public void takeImageWithCamera_WhenPendingResultExists_FinishesWithAlreadyActiv } @Test - public void - takeImageWithCamera_WhenAnActivityCanHandleCameraIntent_LaunchesTakeWithCameraIntent() { + public void takeImageWithCamera_WhenHasNoCameraPermission_RequestsForPermission() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(false); + when(mockPermissionManager.needRequestCameraPermission()).thenReturn(true); + + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockPermissionManager) + .askForPermission( + Manifest.permission.CAMERA, ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION); + } + + @Test + public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermission() { + when(mockPermissionManager.needRequestCameraPermission()).thenReturn(false); when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); - MockedStatic mockStaticFile = Mockito.mockStatic(File.class); - mockStaticFile - .when(() -> File.createTempFile(any(), any(), any())) - .thenReturn(new File("/tmpfile")); + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); - try { - ImagePickerDelegate delegate = createDelegate(); - delegate.takeImageWithCamera(mockMethodCall, mockResult); + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + } - verify(mockActivity) - .startActivityForResult( - any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); - } finally { - mockStaticFile.close(); - } + @Test + public void + takeImageWithCamera_WhenHasCameraPermission_AndAnActivityCanHandleCameraIntent_LaunchesTakeWithCameraIntent() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); + when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); + + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); } @Test public void - takeImageWithCamera_WhenNoActivityToHandleCameraIntent_FinishesWithNoCamerasAvailableError() { + takeImageWithCamera_WhenHasCameraPermission_AndNoActivityToHandleCameraIntent_FinishesWithNoCamerasAvailableError() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(false); ImagePickerDelegate delegate = createDelegate(); @@ -158,6 +183,7 @@ public void takeImageWithCamera_WhenPendingResultExists_FinishesWithAlreadyActiv @Test public void takeImageWithCamera_WritesImageToCacheDirectory() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); MockedStatic mockStaticFile = Mockito.mockStatic(File.class); @@ -165,16 +191,57 @@ public void takeImageWithCamera_WritesImageToCacheDirectory() { .when(() -> File.createTempFile(any(), any(), any())) .thenReturn(new File("/tmpfile")); - try { - ImagePickerDelegate delegate = createDelegate(); - delegate.takeImageWithCamera(mockMethodCall, mockResult); + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); - mockStaticFile.verify( - () -> File.createTempFile(any(), eq(".jpg"), eq(new File("/image_picker_cache"))), - times(1)); - } finally { - mockStaticFile.close(); - } + mockStaticFile.verify( + () -> File.createTempFile(any(), eq(".jpg"), eq(new File("/image_picker_cache"))), + times(1)); + } + + @Test + public void onRequestPermissionsResult_WhenCameraPermissionDenied_FinishesWithError() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.onRequestPermissionsResult( + ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION, + new String[] {Manifest.permission.CAMERA}, + new int[] {PackageManager.PERMISSION_DENIED}); + + verify(mockResult).error("camera_access_denied", "The user did not allow camera access.", null); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onRequestTakeVideoPermissionsResult_WhenCameraPermissionGranted_LaunchesTakeVideoWithCameraIntent() { + when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onRequestPermissionsResult( + ImagePickerDelegate.REQUEST_CAMERA_VIDEO_PERMISSION, + new String[] {Manifest.permission.CAMERA}, + new int[] {PackageManager.PERMISSION_GRANTED}); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA)); + } + + @Test + public void + onRequestTakeImagePermissionsResult_WhenCameraPermissionGranted_LaunchesTakeWithCameraIntent() { + when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onRequestPermissionsResult( + ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION, + new String[] {Manifest.permission.CAMERA}, + new int[] {PackageManager.PERMISSION_GRANTED}); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); } @Test @@ -295,6 +362,7 @@ private ImagePickerDelegate createDelegate() { null, null, cache, + mockPermissionManager, mockIntentResolver, mockFileUriResolver, mockFileUtils); @@ -303,11 +371,12 @@ private ImagePickerDelegate createDelegate() { private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { return new ImagePickerDelegate( mockActivity, - new File("/image_picker_cache"), + null, mockImageResizer, mockResult, mockMethodCall, cache, + mockPermissionManager, mockIntentResolver, mockFileUriResolver, mockFileUtils); diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 4ca29b4d33d1..bf42015e3193 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.0+2 +version: 0.8.0+3 environment: sdk: ">=2.12.0 <3.0.0" From f7157ea8fe04ba9dd2ec95047e708e0e151d4d1e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 8 Jun 2021 15:24:05 +0200 Subject: [PATCH 028/364] [in_app_purchase] Added userIds to google play purchase (#4020) --- .../in_app_purchase_android/CHANGELOG.md | 4 ++ .../plugins/inapppurchase/Translator.java | 6 +++ .../plugins/inapppurchase/TranslatorTest.java | 36 +++++++++++++++- .../purchase_wrapper.dart | 42 +++++++++++++------ .../purchase_wrapper.g.dart | 4 ++ .../in_app_purchase_android/pubspec.yaml | 2 +- .../purchase_wrapper_test.dart | 4 ++ 7 files changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 949fe907ab5d..61754627a595 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.2 + +* Added support for the obfuscatedAccountId and obfuscatedProfileId in the PurchaseWrapper. + ## 0.1.1 * Added support to request a list of active subscriptions and non-consumed one-time purchases on Android, through the `InAppPurchaseAndroidPlatformAddition.queryPastPurchases` method. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 37e30cbfed06..079c18ab8b5c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -5,6 +5,7 @@ package io.flutter.plugins.inapppurchase; import androidx.annotation.Nullable; +import com.android.billingclient.api.AccountIdentifiers; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase.PurchasesResult; @@ -63,6 +64,11 @@ static HashMap fromPurchase(Purchase purchase) { info.put("developerPayload", purchase.getDeveloperPayload()); info.put("isAcknowledged", purchase.isAcknowledged()); info.put("purchaseState", purchase.getPurchaseState()); + AccountIdentifiers accountIdentifiers = purchase.getAccountIdentifiers(); + if (accountIdentifiers != null) { + info.put("obfuscatedAccountId", accountIdentifiers.getObfuscatedAccountId()); + info.put("obfuscatedProfileId", accountIdentifiers.getObfuscatedProfileId()); + } return info; } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index 47147e772bce..e65afcf42467 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -5,9 +5,13 @@ package io.flutter.plugins.inapppurchase; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import androidx.annotation.NonNull; +import com.android.billingclient.api.AccountIdentifiers; import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.Purchase; @@ -26,7 +30,7 @@ public class TranslatorTest { private static final String SKU_DETAIL_EXAMPLE_JSON = "{\"productId\":\"example\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}"; private static final String PURCHASE_EXAMPLE_JSON = - "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; + "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\", \"obfuscatedAccountId\":\"Account101\", \"obfuscatedProfileId\": \"Profile105\"}"; @Test public void fromSkuDetail() throws JSONException { @@ -63,6 +67,16 @@ public void fromPurchase() throws JSONException { assertSerialized(expected, Translator.fromPurchase(expected)); } + @Test + public void fromPurchaseWithoutAccountIds() throws JSONException { + final Purchase expected = + new PurchaseWithoutAccountIdentifiers(PURCHASE_EXAMPLE_JSON, "signature"); + Map serialized = Translator.fromPurchase(expected); + assertNotNull(serialized.get("orderId")); + assertNull(serialized.get("obfuscatedProfileId")); + assertNull(serialized.get("obfuscatedAccountId")); + } + @Test public void fromPurchaseHistoryRecord() throws JSONException { final PurchaseHistoryRecord expected = @@ -200,6 +214,14 @@ private void assertSerialized(Purchase expected, Map serialized) assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); assertEquals(expected.isAcknowledged(), serialized.get("isAcknowledged")); assertEquals(expected.getPurchaseState(), serialized.get("purchaseState")); + assertNotNull(expected.getAccountIdentifiers().getObfuscatedAccountId()); + assertEquals( + expected.getAccountIdentifiers().getObfuscatedAccountId(), + serialized.get("obfuscatedAccountId")); + assertNotNull(expected.getAccountIdentifiers().getObfuscatedProfileId()); + assertEquals( + expected.getAccountIdentifiers().getObfuscatedProfileId(), + serialized.get("obfuscatedProfileId")); } private void assertSerialized(PurchaseHistoryRecord expected, Map serialized) { @@ -211,3 +233,15 @@ private void assertSerialized(PurchaseHistoryRecord expected, Map map) => @@ -136,6 +140,20 @@ class PurchaseWrapper { /// [BillingClient.acknowledgePurchase] should only be called when the `purchaseState` is [PurchaseStateWrapper.purchased]. /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. final PurchaseStateWrapper purchaseState; + + /// The obfuscatedAccountId specified when making a purchase. + /// + /// The [obfuscatedAccountId] can either be set in + /// [PurchaseParam.applicationUserName] when using the [InAppPurchasePlatform] + /// or by setting the [accountId] in [BillingClient.launchBillingFlow]. + final String? obfuscatedAccountId; + + /// The obfuscatedProfileId can be used when there are multiple profiles + /// withing one account. The obfuscatedProfileId should be specified when + /// making a purchase. This property can only be set on a purchase by + /// directly calling [BillingClient.launchBillingFlow] and is not available + /// on the generic [InAppPurchasePlatform]. + final String? obfuscatedProfileId; } /// Data structure representing a purchase history record. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart index 5f0d936e09c2..5607dbdd8cb2 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart @@ -20,6 +20,8 @@ PurchaseWrapper _$PurchaseWrapperFromJson(Map json) { isAcknowledged: json['isAcknowledged'] as bool? ?? false, purchaseState: const PurchaseStateConverter().fromJson(json['purchaseState'] as int?), + obfuscatedAccountId: json['obfuscatedAccountId'] as String?, + obfuscatedProfileId: json['obfuscatedProfileId'] as String?, ); } @@ -37,6 +39,8 @@ Map _$PurchaseWrapperToJson(PurchaseWrapper instance) => 'isAcknowledged': instance.isAcknowledged, 'purchaseState': const PurchaseStateConverter().toJson(instance.purchaseState), + 'obfuscatedAccountId': instance.obfuscatedAccountId, + 'obfuscatedProfileId': instance.obfuscatedProfileId, }; PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) { diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 4e78874cfc49..900fa4374bd0 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.1 +version: 0.1.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart index a3e80a89fa7e..bb7ff8535c7a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -19,6 +19,8 @@ final PurchaseWrapper dummyPurchase = PurchaseWrapper( developerPayload: 'dummy payload', isAcknowledged: true, purchaseState: PurchaseStateWrapper.purchased, + obfuscatedAccountId: 'Account101', + obfuscatedProfileId: 'Profile103', ); final PurchaseWrapper dummyUnacknowledgedPurchase = PurchaseWrapper( @@ -191,6 +193,8 @@ Map buildPurchaseMap(PurchaseWrapper original) { 'developerPayload': original.developerPayload, 'purchaseState': PurchaseStateConverter().toJson(original.purchaseState), 'isAcknowledged': original.isAcknowledged, + 'obfuscatedAccountId': original.obfuscatedAccountId, + 'obfuscatedProfileId': original.obfuscatedProfileId, }; } From 117856f9e0b337dd5322bf28aa216a000d3bde0e Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 8 Jun 2021 10:14:29 -0700 Subject: [PATCH 029/364] [video_player] Remove pre-stable warning from README (#4029) video_player was promoted to 1.0 a while ago, so should no longer have language in the README suggesting that it's in an unfinished/beta state. --- packages/video_player/video_player/CHANGELOG.md | 3 ++- packages/video_player/video_player/README.md | 4 ---- packages/video_player/video_player/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index b8af5b4f1e60..5084021b33de 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.1.6 +* Remove obsolete pre-1.0 warning from README. * Add iOS unit and UI integration test targets. ## 2.1.5 diff --git a/packages/video_player/video_player/README.md b/packages/video_player/video_player/README.md index 871a84c5927c..7140527afb9f 100644 --- a/packages/video_player/video_player/README.md +++ b/packages/video_player/video_player/README.md @@ -6,10 +6,6 @@ A Flutter plugin for iOS, Android and Web for playing back video on a Widget sur ![The example app running in iOS](https://github.com/flutter/plugins/blob/master/packages/video_player/video_player/doc/demo_ipod.gif?raw=true) -*Note*: This plugin is still under development, and some APIs might not be available yet. -[Feedback welcome](https://github.com/flutter/flutter/issues) and -[Pull Requests](https://github.com/flutter/plugins/pulls) are most welcome! - ## Installation First, add `video_player` as a [dependency in your pubspec.yaml file](https://flutter.dev/using-packages/). diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 8f98b0702fab..ed78213cbc2b 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.5 +version: 2.1.6 environment: sdk: ">=2.12.0 <3.0.0" From af8280ee638feb2af464324865d8609bdd9d328d Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Tue, 8 Jun 2021 16:29:03 -0700 Subject: [PATCH 030/364] Fix typo; s/unnecessary_imports/unnecessary_import (#4028) --- analysis_options.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 1359851bd3a7..901067736edc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -49,8 +49,9 @@ analyzer: # Allow null checks for as long as mixed mode is officially supported. unnecessary_null_comparison: false always_require_non_null_named_parameters: false # not needed with nnbd - # TODO(https://github.com/flutter/flutter/issues/74381) - unnecessary_imports: ignore + # TODO(https://github.com/flutter/flutter/issues/74381): + # Clean up existing unnecessary imports, and remove line to ignore. + unnecessary_import: ignore exclude: # Ignore generated files - '**/*.g.dart' From b9382797a95f0058ca5d7e1d8d326bd0274661d3 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 9 Jun 2021 11:00:04 -0700 Subject: [PATCH 031/364] [flutter_plugin_tools] Migrate more commands to NNBD (#4026) Migrates: - `all_plugins_app` - `podspecs` - `firebase-test-lab` Minor functional changes to `firebase-test-lab` based on issues highlighted by the migration: - The build ID used in the path is now a) passable, and b) given a fallback value in the path that isn't "null" - Flag setup will no longer assume that `$HOME` must be set in the environment. - Adds a --build-id flag to `firebase-test-lab` instead of hard-coding the use of `CIRRUS_BUILD_ID`. The default is still `CIRRUS_BUILD_ID` so no CI changes are needed. Part of https://github.com/flutter/flutter/issues/81912 --- script/tool/CHANGELOG.md | 6 ++ .../src/create_all_plugins_app_command.dart | 15 ++-- .../lib/src/firebase_test_lab_command.dart | 73 +++++++++++-------- .../tool/lib/src/lint_podspecs_command.dart | 5 +- .../create_all_plugins_app_command_test.dart | 10 +-- script/tool/test/firebase_test_lab_test.dart | 32 ++++---- .../tool/test/lint_podspecs_command_test.dart | 24 +++--- script/tool/test/mocks.dart | 8 ++ script/tool/test/util.dart | 10 +-- 9 files changed, 99 insertions(+), 84 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index bd0875c2dbb0..2ada2cc30cbe 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,9 @@ +## NEXT + +- Add a --build-id flag to `firebase-test-lab` instead of hard-coding the use of + `CIRRUS_BUILD_ID`. `CIRRUS_BUILD_ID` is the default value for that flag, for backward + compatibility. + ## 0.2.0 - Remove `xctest`'s `--skip`, which is redundant with `--ignore`. diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index 9de7f1b904a1..cd5b85e45ac0 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -2,9 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - -import 'dart:async'; import 'dart:io' as io; import 'package:file/file.dart'; @@ -18,17 +15,17 @@ class CreateAllPluginsAppCommand extends PluginCommand { /// Creates an instance of the builder command. CreateAllPluginsAppCommand( Directory packagesDir, { - this.pluginsRoot, - }) : super(packagesDir) { - pluginsRoot ??= packagesDir.fileSystem.currentDirectory; - appDirectory = pluginsRoot.childDirectory('all_plugins'); + Directory? pluginsRoot, + }) : pluginsRoot = pluginsRoot ?? packagesDir.fileSystem.currentDirectory, + super(packagesDir) { + appDirectory = this.pluginsRoot.childDirectory('all_plugins'); } /// The root directory of the plugin repository. Directory pluginsRoot; /// The location of the synthesized app project. - Directory appDirectory; + late Directory appDirectory; @override String get description => @@ -177,7 +174,7 @@ description: ${pubspec.description} version: ${pubspec.version} -environment:${_pubspecMapString(pubspec.environment)} +environment:${_pubspecMapString(pubspec.environment!)} dependencies:${_pubspecMapString(pubspec.dependencies)} diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 6db0d629e59f..741d8569322b 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'dart:io' as io; @@ -27,15 +25,25 @@ class FirebaseTestLabCommand extends PluginCommand { defaultsTo: 'flutter-infra', help: 'The Firebase project name.', ); + final String? homeDir = io.Platform.environment['HOME']; argParser.addOption('service-key', defaultsTo: - p.join(io.Platform.environment['HOME'], 'gcloud-service-key.json')); + homeDir == null ? null : p.join(homeDir, 'gcloud-service-key.json'), + help: 'The path to the service key for gcloud authentication.\n' + r'If not provided, \$HOME/gcloud-service-key.json will be ' + r'assumed if $HOME is set.'); argParser.addOption('test-run-id', defaultsTo: const Uuid().v4(), help: 'Optional string to append to the results path, to avoid conflicts. ' 'Randomly chosen on each invocation if none is provided. ' 'The default shown here is just an example.'); + argParser.addOption('build-id', + defaultsTo: + io.Platform.environment['CIRRUS_BUILD_ID'] ?? 'unknown_build', + help: + 'Optional string to append to the results path, to avoid conflicts. ' + r'Defaults to $CIRRUS_BUILD_ID if that is set.'); argParser.addMultiOption('device', splitCommas: false, defaultsTo: [ @@ -66,38 +74,43 @@ class FirebaseTestLabCommand extends PluginCommand { final Print _print; - Completer _firebaseProjectConfigured; + Completer? _firebaseProjectConfigured; Future _configureFirebaseProject() async { if (_firebaseProjectConfigured != null) { - return _firebaseProjectConfigured.future; - } else { - _firebaseProjectConfigured = Completer(); + return _firebaseProjectConfigured!.future; } - await processRunner.run( - 'gcloud', - [ - 'auth', - 'activate-service-account', - '--key-file=${getStringArg('service-key')}', - ], - exitOnError: true, - logOnError: true, - ); - final int exitCode = await processRunner.runAndStream('gcloud', [ - 'config', - 'set', - 'project', - getStringArg('project'), - ]); - if (exitCode == 0) { - _print('\nFirebase project configured.'); - return; + _firebaseProjectConfigured = Completer(); + + final String serviceKey = getStringArg('service-key'); + if (serviceKey.isEmpty) { + _print('No --service-key provided; skipping gcloud authorization'); } else { - _print( - '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.'); + await processRunner.run( + 'gcloud', + [ + 'auth', + 'activate-service-account', + '--key-file=$serviceKey', + ], + exitOnError: true, + logOnError: true, + ); + final int exitCode = await processRunner.runAndStream('gcloud', [ + 'config', + 'set', + 'project', + getStringArg('project'), + ]); + if (exitCode == 0) { + _print('\nFirebase project configured.'); + return; + } else { + _print( + '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.'); + } } - _firebaseProjectConfigured.complete(null); + _firebaseProjectConfigured!.complete(null); } @override @@ -212,7 +225,7 @@ class FirebaseTestLabCommand extends PluginCommand { failingPackages.add(packageName); continue; } - final String buildId = io.Platform.environment['CIRRUS_BUILD_ID']; + final String buildId = getStringArg('build-id'); final String testRunId = getStringArg('test-run-id'); final String resultsDir = 'plugins_android_test/$packageName/$buildId/$testRunId/${resultsCounter++}/'; diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 72bb6af3f64a..364653bd13ba 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -2,9 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - -import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -122,7 +119,7 @@ class LintPodspecsCommand extends PluginCommand { } Future _runPodLint(String podspecPath, - {bool libraryLint}) async { + {required bool libraryLint}) async { final bool allowWarnings = (getStringListArg('ignore-warnings')) .contains(p.basenameWithoutExtension(podspecPath)); final List arguments = [ diff --git a/script/tool/test/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index b3cbd592a631..5bde5e0dc004 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; @@ -14,11 +12,11 @@ import 'util.dart'; void main() { group('$CreateAllPluginsAppCommand', () { - CommandRunner runner; + late CommandRunner runner; FileSystem fileSystem; - Directory testRoot; - Directory packagesDir; - Directory appDir; + late Directory testRoot; + late Directory packagesDir; + late Directory appDir; setUp(() { // Since the core of this command is a call to 'flutter create', the test diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart index 74809007c295..aa8be17d6794 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:io'; import 'package:args/command_runner.dart'; @@ -19,10 +17,10 @@ import 'util.dart'; void main() { group('$FirebaseTestLabCommand', () { FileSystem fileSystem; - Directory packagesDir; - List printedMessages; - CommandRunner runner; - RecordingProcessRunner processRunner; + late Directory packagesDir; + late List printedMessages; + late CommandRunner runner; + late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); @@ -31,7 +29,7 @@ void main() { processRunner = RecordingProcessRunner(); final FirebaseTestLabCommand command = FirebaseTestLabCommand(packagesDir, processRunner: processRunner, - print: (Object message) => printedMessages.add(message.toString())); + print: (Object? message) => printedMessages.add(message.toString())); runner = CommandRunner( 'firebase_test_lab_command', 'Test for $FirebaseTestLabCommand'); @@ -97,6 +95,8 @@ void main() { 'model=seoul,version=26', '--test-run-id', 'testRunId', + '--build-id', + 'buildId', ]); expect( @@ -130,7 +130,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -140,7 +140,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -150,7 +150,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/2/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/2/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -160,7 +160,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/3/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/3/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ]), @@ -196,6 +196,8 @@ void main() { 'model=flame,version=29', '--test-run-id', 'testRunId', + '--build-id', + 'buildId', '--enable-experiment=exp1', ]); @@ -221,7 +223,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/0/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -231,7 +233,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/1/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -241,7 +243,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/2/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/2/ --device model=flame,version=29' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -251,7 +253,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/3/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/3/ --device model=flame,version=29' .split(' '), '/packages/plugin/example'), ]), diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 349607b0ca75..0183704f72c3 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -2,15 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/lint_podspecs_command.dart'; -import 'package:mockito/mockito.dart'; import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -19,24 +15,24 @@ import 'util.dart'; void main() { group('$LintPodspecsCommand', () { FileSystem fileSystem; - Directory packagesDir; - CommandRunner runner; - MockPlatform mockPlatform; - final RecordingProcessRunner processRunner = RecordingProcessRunner(); - List printedMessages; + late Directory packagesDir; + late CommandRunner runner; + late MockPlatform mockPlatform; + late RecordingProcessRunner processRunner; + late List printedMessages; setUp(() { fileSystem = MemoryFileSystem(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); printedMessages = []; - mockPlatform = MockPlatform(); - when(mockPlatform.isMacOS).thenReturn(true); + mockPlatform = MockPlatform(isMacOS: true); + processRunner = RecordingProcessRunner(); final LintPodspecsCommand command = LintPodspecsCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, - print: (Object message) => printedMessages.add(message.toString()), + print: (Object? message) => printedMessages.add(message.toString()), ); runner = @@ -53,7 +49,7 @@ void main() { ['plugin1.podspec'], ]); - when(mockPlatform.isMacOS).thenReturn(false); + mockPlatform.isMacOS = false; await runner.run(['podspecs']); expect( @@ -172,5 +168,3 @@ void main() { }); }); } - -class MockPlatform extends Mock implements Platform {} diff --git a/script/tool/test/mocks.dart b/script/tool/test/mocks.dart index 66267ec58255..ba6a03da7bcf 100644 --- a/script/tool/test/mocks.dart +++ b/script/tool/test/mocks.dart @@ -7,6 +7,14 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; + +class MockPlatform extends Mock implements Platform { + MockPlatform({this.isMacOS = false}); + + @override + bool isMacOS; +} class MockProcess extends Mock implements io.Process { final Completer exitCodeCompleter = Completer(); diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index a0a316f95dfc..c9d4ed23d08a 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -257,13 +257,13 @@ class RecordingProcessRunner extends ProcessRunner { Encoding stderrEncoding = io.systemEncoding, }) async { recordedCalls.add(ProcessCall(executable, args, workingDir?.path)); - io.ProcessResult? result; final io.Process? process = processToReturn; - if (process != null) { - result = io.ProcessResult(process.pid, await process.exitCode, - resultStdout ?? process.stdout, resultStderr ?? process.stderr); - } + final io.ProcessResult result = process == null + ? io.ProcessResult(1, 1, '', '') + : io.ProcessResult(process.pid, await process.exitCode, + resultStdout ?? process.stdout, resultStderr ?? process.stderr); + return Future.value(result); } From 9e0013bd6e1b1efe3443063dbaf7966ff41e73d6 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 10 Jun 2021 10:37:41 -0700 Subject: [PATCH 032/364] Link to this repo's guide in the PR template (#4033) The plugins repo contributor's guide mostly defers to the main one, which it links to, but includes repository-specific information. Also simplifies the section on style guides, while making it more comprehensive, by linking to the style section of the contributor guide. --- .github/PULL_REQUEST_TEMPLATE.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d470ac18bf5c..3972cd29b8c7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. -- [ ] I read and followed the [Flutter Style Guide] and the [C++, Objective-C, Java style guides]. (Note that unlike the flutter/flutter repo, the flutter/plugins repo does use `dart format`. See [plugin_tool format]) +- [ ] I read and followed the [relevant style guides] and ran [the auto-formatter]. (Note that unlike the flutter/flutter repo, the flutter/plugins repo does use `dart format`.) - [ ] I signed the [CLA]. - [ ] The title of the PR starts with the name of the plugin surrounded by square brackets, e.g. `[shared_preferences]` - [ ] I listed at least one issue that this PR fixes in the description above. @@ -21,13 +21,12 @@ If you need help, consider asking for advice on the #hackers-new channel on [Discord]. -[Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview +[Contributor Guide]: https://github.com/flutter/plugins/blob/master/CONTRIBUTING.md [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene -[Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo -[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/master/CONTRIBUTING.md#style +[relevant style guides]: https://github.com/flutter/plugins/blob/master/CONTRIBUTING.md#style [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat [pub versioning philosophy]: https://dart.dev/tools/pub/versioning -[plugin_tool format]: https://github.com/flutter/plugins/blob/master/script/tool/README.md#format-code +[the auto-formatter]: https://github.com/flutter/plugins/blob/master/script/tool/README.md#format-code From e649ccd89d4e7cf7e2d6a5d7fce971762a58acc4 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 10 Jun 2021 12:26:58 -0700 Subject: [PATCH 033/364] Separate out the deprecated plugins in README.md (#4009) Move all of the deprecated plugins to a secondary table, and replace their extened pub info (points, popularity, likes) with pointers to the replacement plugins. --- CONTRIBUTING.md | 24 +++++++----------------- README.md | 25 +++++++++++++++++-------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c4115fb640f..a5fc990033ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,22 +23,12 @@ Additional resources specific to the plugins repository: ## Important note -As of January 2021, we are no longer accepting non-critical PRs for plugins -for which there is a corresponding [Flutter Community Plus -Plugin](https://plus.fluttercommunity.dev/), as we hope in time to be able -to transition users to those versions of the plugins. If you have a PR for -something other than a critical issue (crashes, build failures, null safety, etc.) -for any of the following plugins, we encourage you to submit it -[there](https://github.com/fluttercommunity/plus_plugins/pulls) instead: -- `android_alarm_manager` -- `android_intent` -- `battery` -- `connectivity` -- `device_info` -- `package_info` -- `sensors` -- `share` -- `wifi_info_flutter` (corresponds to `network_info_plus`) +As of January 2021, we are no longer accepting non-critical PRs for the +[deprecated plugins](./README.md#deprecated), as all new development should +happen in the Flutter Community Plus replacements. If you have a PR for +something other than a critical issue (crashes, build failures, security issues) +in one of those pluigns, please [submit it to the Flutter Community Plus +replacement](https://github.com/fluttercommunity/plus_plugins/pulls) instead. ## Other notes @@ -57,7 +47,7 @@ use, and use auto-formatters: `google-java-format` - [Objective-C](https://google.github.io/styleguide/objcguide.html) formatted with `clang-format` - + ### The review process Reviewing PRs often requires a non-trivial amount of time. We prioritize issues, not PRs, so that we use our maintainers' time in the most impactful way. Issues pertaining to this repository are managed in the [flutter/flutter issue tracker and are labeled with "plugin"](https://github.com/flutter/flutter/issues?q=is%3Aopen+is%3Aissue+label%3Aplugin+sort%3Areactions-%2B1-desc). Non-trivial PRs should have an associated issue that will be used for prioritization. See the [prioritization section](https://github.com/flutter/flutter/wiki/Issue-hygiene#prioritization) in the Flutter wiki to understand how issues are prioritized. diff --git a/README.md b/README.md index b5b335ae27ca..1dd6d80f2a11 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,7 @@ These are the available plugins in this repository. | Plugin | Pub | Points | Popularity | Likes | |--------|-----|--------|------------|-------| -| [android_alarm_manager](./packages/android_alarm_manager/) | [![pub package](https://img.shields.io/pub/v/android_alarm_manager.svg)](https://pub.dev/packages/android_alarm_manager) | [![pub points](https://badges.bar/android_alarm_manager/pub%20points)](https://pub.dev/packages/android_alarm_manager/score) | [![popularity](https://badges.bar/android_alarm_manager/popularity)](https://pub.dev/packages/android_alarm_manager/score) | [![likes](https://badges.bar/android_alarm_manager/likes)](https://pub.dev/packages/android_alarm_manager/score) | -| [android_intent](./packages/android_intent/) | [![pub package](https://img.shields.io/pub/v/android_intent.svg)](https://pub.dev/packages/android_intent) | [![pub points](https://badges.bar/android_intent/pub%20points)](https://pub.dev/packages/android_intent/score) | [![popularity](https://badges.bar/android_intent/popularity)](https://pub.dev/packages/android_intent/score) | [![likes](https://badges.bar/android_intent/likes)](https://pub.dev/packages/android_intent/score) | -| [battery](./packages/battery/) | [![pub package](https://img.shields.io/pub/v/battery.svg)](https://pub.dev/packages/battery) | [![pub points](https://badges.bar/battery/pub%20points)](https://pub.dev/packages/battery/score) | [![popularity](https://badges.bar/battery/popularity)](https://pub.dev/packages/battery/score) | [![likes](https://badges.bar/battery/likes)](https://pub.dev/packages/battery/score) | | [camera](./packages/camera/) | [![pub package](https://img.shields.io/pub/v/camera.svg)](https://pub.dev/packages/camera) | [![pub points](https://badges.bar/camera/pub%20points)](https://pub.dev/packages/camera/score) | [![popularity](https://badges.bar/camera/popularity)](https://pub.dev/packages/camera/score) | [![likes](https://badges.bar/camera/likes)](https://pub.dev/packages/camera/score) | -| [connectivity](./packages/connectivity/) | [![pub package](https://img.shields.io/pub/v/connectivity.svg)](https://pub.dev/packages/connectivity) | [![pub points](https://badges.bar/connectivity/pub%20points)](https://pub.dev/packages/connectivity/score) | [![popularity](https://badges.bar/connectivity/popularity)](https://pub.dev/packages/connectivity/score) | [![likes](https://badges.bar/connectivity/likes)](https://pub.dev/packages/connectivity/score) | -| [device_info](./packages/device_info/) | [![pub package](https://img.shields.io/pub/v/device_info.svg)](https://pub.dev/packages/device_info) | [![pub points](https://badges.bar/device_info/pub%20points)](https://pub.dev/packages/device_info/score) | [![popularity](https://badges.bar/device_info/popularity)](https://pub.dev/packages/device_info/score) | [![likes](https://badges.bar/device_info/likes)](https://pub.dev/packages/device_info/score) | | [espresso](./packages/espresso/) | [![pub package](https://img.shields.io/pub/v/espresso.svg)](https://pub.dev/packages/espresso) | [![pub points](https://badges.bar/espresso/pub%20points)](https://pub.dev/packages/espresso/score) | [![popularity](https://badges.bar/espresso/popularity)](https://pub.dev/packages/espresso/score) | [![likes](https://badges.bar/espresso/likes)](https://pub.dev/packages/espresso/score) | | [flutter_plugin_android_lifecycle](./packages/flutter_plugin_android_lifecycle/) | [![pub package](https://img.shields.io/pub/v/flutter_plugin_android_lifecycle.svg)](https://pub.dev/packages/flutter_plugin_android_lifecycle) | [![pub points](https://badges.bar/flutter_plugin_android_lifecycle/pub%20points)](https://pub.dev/packages/flutter_plugin_android_lifecycle/score) | [![popularity](https://badges.bar/flutter_plugin_android_lifecycle/popularity)](https://pub.dev/packages/flutter_plugin_android_lifecycle/score) | [![likes](https://badges.bar/flutter_plugin_android_lifecycle/likes)](https://pub.dev/packages/flutter_plugin_android_lifecycle/score) | | [google_maps_flutter](./packages/google_maps_flutter) | [![pub package](https://img.shields.io/pub/v/google_maps_flutter.svg)](https://pub.dev/packages/google_maps_flutter) | [![pub points](https://badges.bar/google_maps_flutter/pub%20points)](https://pub.dev/packages/google_maps_flutter/score) | [![popularity](https://badges.bar/google_maps_flutter/popularity)](https://pub.dev/packages/google_maps_flutter/score) | [![likes](https://badges.bar/google_maps_flutter/likes)](https://pub.dev/packages/google_maps_flutter/score) | @@ -55,13 +50,27 @@ These are the available plugins in this repository. | [in_app_purchase](./packages/in_app_purchase/) | [![pub package](https://img.shields.io/pub/v/in_app_purchase.svg)](https://pub.dev/packages/in_app_purchase) | [![pub points](https://badges.bar/in_app_purchase/pub%20points)](https://pub.dev/packages/in_app_purchase/score) | [![popularity](https://badges.bar/in_app_purchase/popularity)](https://pub.dev/packages/in_app_purchase/score) | [![likes](https://badges.bar/in_app_purchase/likes)](https://pub.dev/packages/in_app_purchase/score) | | [ios_platform_images](./packages/ios_platform_images/) | [![pub package](https://img.shields.io/pub/v/ios_platform_images.svg)](https://pub.dev/packages/ios_platform_images) | [![pub points](https://badges.bar/ios_platform_images/pub%20points)](https://pub.dev/packages/ios_platform_images/score) | [![popularity](https://badges.bar/ios_platform_images/popularity)](https://pub.dev/packages/ios_platform_images/score) | [![likes](https://badges.bar/ios_platform_images/likes)](https://pub.dev/packages/ios_platform_images/score) | | [local_auth](./packages/local_auth/) | [![pub package](https://img.shields.io/pub/v/local_auth.svg)](https://pub.dev/packages/local_auth) | [![pub points](https://badges.bar/local_auth/pub%20points)](https://pub.dev/packages/local_auth/score) | [![popularity](https://badges.bar/local_auth/popularity)](https://pub.dev/packages/local_auth/score) | [![likes](https://badges.bar/local_auth/likes)](https://pub.dev/packages/local_auth/score) | -| [package_info](./packages/package_info/) | [![pub package](https://img.shields.io/pub/v/package_info.svg)](https://pub.dev/packages/package_info) | [![pub points](https://badges.bar/package_info/pub%20points)](https://pub.dev/packages/package_info/score) | [![popularity](https://badges.bar/package_info/popularity)](https://pub.dev/packages/package_info/score) | [![likes](https://badges.bar/package_info/likes)](https://pub.dev/packages/package_info/score) | | [path_provider](./packages/path_provider/) | [![pub package](https://img.shields.io/pub/v/path_provider.svg)](https://pub.dev/packages/path_provider) | [![pub points](https://badges.bar/path_provider/pub%20points)](https://pub.dev/packages/path_provider/score) | [![popularity](https://badges.bar/path_provider/popularity)](https://pub.dev/packages/path_provider/score) | [![likes](https://badges.bar/path_provider/likes)](https://pub.dev/packages/path_provider/score) | | [plugin_platform_interface](./packages/plugin_platform_interface/) | [![pub package](https://img.shields.io/pub/v/plugin_platform_interface.svg)](https://pub.dev/packages/plugin_platform_interface) | [![pub points](https://badges.bar/plugin_platform_interface/pub%20points)](https://pub.dev/packages/plugin_platform_interface/score) | [![popularity](https://badges.bar/plugin_platform_interface/popularity)](https://pub.dev/packages/plugin_platform_interface/score) | [![likes](https://badges.bar/plugin_platform_interface/likes)](https://pub.dev/packages/plugin_platform_interface/score) | | [quick_actions](./packages/quick_actions/) | [![pub package](https://img.shields.io/pub/v/quick_actions.svg)](https://pub.dev/packages/quick_actions) | [![pub points](https://badges.bar/quick_actions/pub%20points)](https://pub.dev/packages/quick_actions/score) | [![popularity](https://badges.bar/quick_actions/popularity)](https://pub.dev/packages/quick_actions/score) | [![likes](https://badges.bar/quick_actions/likes)](https://pub.dev/packages/quick_actions/score) | -| [sensors](./packages/sensors/) | [![pub package](https://img.shields.io/pub/v/sensors.svg)](https://pub.dev/packages/sensors) | [![pub points](https://badges.bar/sensors/pub%20points)](https://pub.dev/packages/sensors/score) | [![popularity](https://badges.bar/sensors/popularity)](https://pub.dev/packages/sensors/score) | [![likes](https://badges.bar/sensors/likes)](https://pub.dev/packages/sensors/score) | -| [share](./packages/share/) | [![pub package](https://img.shields.io/pub/v/share.svg)](https://pub.dev/packages/share) | [![pub points](https://badges.bar/share/pub%20points)](https://pub.dev/packages/share/score) | [![popularity](https://badges.bar/share/popularity)](https://pub.dev/packages/share/score) | [![likes](https://badges.bar/share/likes)](https://pub.dev/packages/share/score) | | [shared_preferences](./packages/shared_preferences/) | [![pub package](https://img.shields.io/pub/v/shared_preferences.svg)](https://pub.dev/packages/shared_preferences) | [![pub points](https://badges.bar/shared_preferences/pub%20points)](https://pub.dev/packages/shared_preferences/score) | [![popularity](https://badges.bar/shared_preferences/popularity)](https://pub.dev/packages/shared_preferences/score) | [![likes](https://badges.bar/shared_preferences/likes)](https://pub.dev/packages/shared_preferences/score) | | [url_launcher](./packages/url_launcher/) | [![pub package](https://img.shields.io/pub/v/url_launcher.svg)](https://pub.dev/packages/url_launcher) | [![pub points](https://badges.bar/url_launcher/pub%20points)](https://pub.dev/packages/url_launcher/score) | [![popularity](https://badges.bar/url_launcher/popularity)](https://pub.dev/packages/url_launcher/score) | [![likes](https://badges.bar/url_launcher/likes)](https://pub.dev/packages/url_launcher/score) | | [video_player](./packages/video_player/) | [![pub package](https://img.shields.io/pub/v/video_player.svg)](https://pub.dev/packages/video_player) | [![pub points](https://badges.bar/video_player/pub%20points)](https://pub.dev/packages/video_player/score) | [![popularity](https://badges.bar/video_player/popularity)](https://pub.dev/packages/video_player/score) | [![likes](https://badges.bar/video_player/likes)](https://pub.dev/packages/video_player/score) | | [webview_flutter](./packages/webview_flutter/) | [![pub package](https://img.shields.io/pub/v/webview_flutter.svg)](https://pub.dev/packages/webview_flutter) | [![pub points](https://badges.bar/webview_flutter/pub%20points)](https://pub.dev/packages/webview_flutter/score) | [![popularity](https://badges.bar/webview_flutter/popularity)](https://pub.dev/packages/webview_flutter/score) | [![likes](https://badges.bar/webview_flutter/likes)](https://pub.dev/packages/webview_flutter/score) | + +### Deprecated + +The following plugins are also part of this repository, but are deprecated in +favor of the [Flutter Community Plus](https://plus.fluttercommunity.dev/) versions. + +| Plugin | Pub | | Replacement | Pub | +|--------|-----|--|-------------|-----| +| [android_alarm_manager](./packages/android_alarm_manager/) | [![pub package](https://img.shields.io/pub/v/android_alarm_manager.svg)](https://pub.dev/packages/android_alarm_manager) | | android_alarm_manager_plus | [![pub package](https://img.shields.io/pub/v/android_alarm_manager_plus.svg)](https://pub.dev/packages/android_alarm_manager_plus) | +| [android_intent](./packages/android_intent/) | [![pub package](https://img.shields.io/pub/v/android_intent.svg)](https://pub.dev/packages/android_intent) | | android_intent_plus | [![pub package](https://img.shields.io/pub/v/android_intent_plus.svg)](https://pub.dev/packages/android_intent_plus) | +| [battery](./packages/battery/) | [![pub package](https://img.shields.io/pub/v/battery.svg)](https://pub.dev/packages/battery) | | battery_plus | [![pub package](https://img.shields.io/pub/v/battery_plus.svg)](https://pub.dev/packages/battery_plus) | +| [connectivity](./packages/connectivity/) | [![pub package](https://img.shields.io/pub/v/connectivity.svg)](https://pub.dev/packages/connectivity) | | connectivity_plus | [![pub package](https://img.shields.io/pub/v/connectivity_plus.svg)](https://pub.dev/packages/connectivity_plus) | +| [device_info](./packages/device_info/) | [![pub package](https://img.shields.io/pub/v/device_info.svg)](https://pub.dev/packages/device_info) | | device_info_plus | [![pub package](https://img.shields.io/pub/v/device_info_plus.svg)](https://pub.dev/packages/device_info_plus) | +| [package_info](./packages/package_info/) | [![pub package](https://img.shields.io/pub/v/package_info.svg)](https://pub.dev/packages/package_info) | | package_info_plus | [![pub package](https://img.shields.io/pub/v/package_info_plus.svg)](https://pub.dev/packages/package_info_plus) | +| [sensors](./packages/sensors/) | [![pub package](https://img.shields.io/pub/v/sensors.svg)](https://pub.dev/packages/sensors) | | sensors_plus | [![pub package](https://img.shields.io/pub/v/sensors_plus.svg)](https://pub.dev/packages/sensors_plus) | +| [share](./packages/share/) | [![pub package](https://img.shields.io/pub/v/share.svg)](https://pub.dev/packages/share) | | share_plus | [![pub package](https://img.shields.io/pub/v/share_plus.svg)](https://pub.dev/packages/share_plus) | +| [wifi_info_flutter](./packages/wifi_info_flutter/) | [![pub package](https://img.shields.io/pub/v/wifi_info_flutter.svg)](https://pub.dev/packages/wifi_info_flutter) | | network_info_plus | [![pub package](https://img.shields.io/pub/v/network_info_plus.svg)](https://pub.dev/packages/network_info_plus) | From 86c3b30122d2da46336d369945e577943f50714d Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Thu, 10 Jun 2021 13:08:48 -0700 Subject: [PATCH 034/364] [google_maps_flutter_web] Document liteMode not being available on the web. (#4025) --- .../google_maps_flutter/google_maps_flutter_web/CHANGELOG.md | 4 ++++ .../google_maps_flutter/google_maps_flutter_web/README.md | 2 ++ .../google_maps_flutter/google_maps_flutter_web/pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 0544ba26df8e..36a4271cb95d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.0+2 + +* Document `liteModeEnabled` is not available on the web. [#83737](https://github.com/flutter/flutter/issues/83737). + ## 0.3.0+1 * Change sizing code of `GoogleMap` widget's `HtmlElementView` so it works well when slotted. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/README.md b/packages/google_maps_flutter/google_maps_flutter_web/README.md index e1c1a5330c56..cfd5f6d8271e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/README.md @@ -49,3 +49,5 @@ There's no "My Location" widget in web ([tracking issue](https://github.com/flut There's no `defaultMarkerWithHue` in web. If you need colored pins/markers, you may need to use your own asset images. Indoor and building layers are still not available on the web. Traffic is. + +Only Android supports "[Lite Mode](https://developers.google.com/maps/documentation/android-sdk/lite)", so the `liteModeEnabled` constructor argument can't be set to `true` on web apps. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index 3eb3afb06fb9..c69b8e55fa1c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.3.0+1 +version: 0.3.0+2 environment: sdk: ">=2.12.0 <3.0.0" From 852051aff103b7d2fcfa80ecc7fdc53287bcaa18 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 10 Jun 2021 14:50:20 -0700 Subject: [PATCH 035/364] Enable macOS XCTest support (#4043) - Adds macOS support to the `xctest` tool command - Adds logic to the tool to check for packages that delegate their implementations to another package, so they can be skipped when running native unit tests - Updates the tool's unit test utility for writing pubspecs to be able to make delegated federated implementation references to test it - Adds initial unit tests to the non-deprecated macOS plugins - Enables macOS XCTesting in CI macOS portion of https://github.com/flutter/flutter/issues/82445 --- .cirrus.yml | 7 +- .../path_provider_macos/CHANGELOG.md | 1 + .../path_provider_macos/example/macos/Podfile | 4 + .../macos/Runner.xcodeproj/project.pbxproj | 175 ++++- .../xcshareddata/xcschemes/Runner.xcscheme | 32 +- .../example/macos/RunnerTests/Info.plist | 22 + .../macos/RunnerTests/RunnerTests.swift | 102 +++ .../shared_preferences_macos/CHANGELOG.md | 4 + .../example/macos/Podfile | 4 + .../macos/Runner.xcodeproj/project.pbxproj | 175 ++++- .../xcshareddata/xcschemes/Runner.xcscheme | 32 +- .../example/macos/RunnerTests/Info.plist | 22 + .../macos/RunnerTests/RunnerTests.swift | 88 +++ .../url_launcher_macos/CHANGELOG.md | 4 + .../url_launcher_macos/example/macos/Podfile | 4 + .../macos/Runner.xcodeproj/project.pbxproj | 196 +++++- .../xcshareddata/xcschemes/Runner.xcscheme | 32 +- .../contents.xcworkspacedata | 3 + .../example/macos/RunnerTests/Info.plist | 22 + .../macos/RunnerTests/RunnerTests.swift | 24 + script/tool/CHANGELOG.md | 2 + script/tool/README.md | 5 +- .../tool/lib/src/build_examples_command.dart | 38 +- script/tool/lib/src/common.dart | 75 ++- .../tool/lib/src/drive_examples_command.dart | 32 +- script/tool/lib/src/xctest_command.dart | 119 +++- script/tool/test/common_test.dart | 621 ++++++++++++------ .../tool/test/publish_check_command_test.dart | 18 +- .../test/publish_plugin_command_test.dart | 79 +-- script/tool/test/util.dart | 134 ++-- script/tool/test/version_check_test.dart | 21 +- script/tool/test/xctest_command_test.dart | 377 +++++++---- 32 files changed, 1856 insertions(+), 618 deletions(-) create mode 100644 packages/path_provider/path_provider_macos/example/macos/RunnerTests/Info.plist create mode 100644 packages/path_provider/path_provider_macos/example/macos/RunnerTests/RunnerTests.swift create mode 100644 packages/shared_preferences/shared_preferences_macos/example/macos/RunnerTests/Info.plist create mode 100644 packages/shared_preferences/shared_preferences_macos/example/macos/RunnerTests/RunnerTests.swift create mode 100644 packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/Info.plist create mode 100644 packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/RunnerTests.swift diff --git a/.cirrus.yml b/.cirrus.yml index 95b739eefbb0..91a656226f38 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -215,7 +215,7 @@ task: build_script: - ./script/tool_runner.sh build-examples --ipa xctest_script: - - ./script/tool_runner.sh xctest --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" + - ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" drive_script: # `drive-examples` contains integration tests, which changes the UI of the application. # This UI change sometimes affects `xctest`. @@ -232,6 +232,9 @@ task: - ./script/build_all_plugins_app.sh macos - name: build-macos+drive-examples env: + # conncectivity_macos is deprecated, so is not getting unit test backfill. + # package_info is deprecated, so is not getting unit test backfill. + PLUGINS_TO_EXCLUDE_MACOS_XCTESTS: "connectivity_macos,package_info" matrix: CHANNEL: "master" CHANNEL: "stable" @@ -239,5 +242,7 @@ task: build_script: - flutter config --enable-macos-desktop - ./script/tool_runner.sh build-examples --macos --no-ipa + xctest_script: + - ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS drive_script: - ./script/tool_runner.sh drive-examples --macos diff --git a/packages/path_provider/path_provider_macos/CHANGELOG.md b/packages/path_provider/path_provider_macos/CHANGELOG.md index ce1b5517968f..d5f9ce860b6f 100644 --- a/packages/path_provider/path_provider_macos/CHANGELOG.md +++ b/packages/path_provider/path_provider_macos/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Add Swift language version to podspec. +* Add native unit tests. ## 2.0.1 diff --git a/packages/path_provider/path_provider_macos/example/macos/Podfile b/packages/path_provider/path_provider_macos/example/macos/Podfile index dade8dfad0dc..e8da8332969a 100644 --- a/packages/path_provider/path_provider_macos/example/macos/Podfile +++ b/packages/path_provider/path_provider_macos/example/macos/Podfile @@ -31,6 +31,10 @@ target 'Runner' do use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/project.pbxproj b/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/project.pbxproj index 51bfc333fa23..a63463993c6e 100644 --- a/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 33EBD3AA26728EA70013E557 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33EBD3A926728EA70013E557 /* RunnerTests.swift */; }; + FEE1C654F5DF2F210CC17B17 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BA0C143378C83246316BE4F7 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,6 +39,13 @@ remoteGlobalIDString = 33CC111A2044C6BA0003C045; remoteInfo = FLX; }; + 33EBD3AC26728EA70013E557 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -54,7 +63,10 @@ /* Begin PBXFileReference section */ 0A1A53CF00FD04D6ED0A8E4A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 0B41979101786837FC1ABC29 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 0B43E5DCF2F998ABCD395373 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 1523F64D34B952AB303BFFA8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1C62AF358280E9A8FA10B127 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; 33CC10ED2044A3C60003C045 /* path_provider_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = path_provider_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -69,9 +81,13 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 33EBD3A726728EA70013E557 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 33EBD3A926728EA70013E557 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 33EBD3AB26728EA70013E557 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 46139048DB9F59D473B61B5E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BA0C143378C83246316BE4F7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F4586DA69948E3A954A2FC9C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -84,6 +100,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD3A426728EA70013E557 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FEE1C654F5DF2F210CC17B17 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -93,8 +117,10 @@ 46139048DB9F59D473B61B5E /* Pods-Runner.debug.xcconfig */, F4586DA69948E3A954A2FC9C /* Pods-Runner.release.xcconfig */, 0A1A53CF00FD04D6ED0A8E4A /* Pods-Runner.profile.xcconfig */, + 0B43E5DCF2F998ABCD395373 /* Pods-RunnerTests.debug.xcconfig */, + 0B41979101786837FC1ABC29 /* Pods-RunnerTests.release.xcconfig */, + 1C62AF358280E9A8FA10B127 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -114,6 +140,7 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 33EBD3A826728EA70013E557 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 30697CBF35C100C7DD4B4699 /* Pods */, @@ -124,6 +151,7 @@ isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* path_provider_example.app */, + 33EBD3A726728EA70013E557 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -150,6 +178,15 @@ path = Flutter; sourceTree = ""; }; + 33EBD3A826728EA70013E557 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 33EBD3A926728EA70013E557 /* RunnerTests.swift */, + 33EBD3AB26728EA70013E557 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; 33FAB671232836740065AC1E /* Runner */ = { isa = PBXGroup; children = ( @@ -167,6 +204,7 @@ isa = PBXGroup; children = ( 1523F64D34B952AB303BFFA8 /* Pods_Runner.framework */, + BA0C143378C83246316BE4F7 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -196,13 +234,32 @@ productReference = 33CC10ED2044A3C60003C045 /* path_provider_example.app */; productType = "com.apple.product-type.application"; }; + 33EBD3A626728EA70013E557 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33EBD3B126728EA70013E557 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 74960BD2BEA7516F537D0F92 /* [CP] Check Pods Manifest.lock */, + 33EBD3A326728EA70013E557 /* Sources */, + 33EBD3A426728EA70013E557 /* Frameworks */, + 33EBD3A526728EA70013E557 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 33EBD3AD26728EA70013E557 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 33EBD3A726728EA70013E557 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0920; + LastSwiftUpdateCheck = 1250; LastUpgradeCheck = 0930; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { @@ -220,6 +277,10 @@ CreatedOnToolsVersion = 9.2; ProvisioningStyle = Manual; }; + 33EBD3A626728EA70013E557 = { + CreatedOnToolsVersion = 12.5; + TestTargetID = 33CC10EC2044A3C60003C045; + }; }; }; buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; @@ -237,6 +298,7 @@ targets = ( 33CC10EC2044A3C60003C045 /* Runner */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + 33EBD3A626728EA70013E557 /* RunnerTests */, ); }; /* End PBXProject section */ @@ -251,6 +313,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD3A526728EA70013E557 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -309,6 +378,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 74960BD2BEA7516F537D0F92 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 82C3ED26F2C350499338A54B /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -344,6 +435,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD3A326728EA70013E557 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33EBD3AA26728EA70013E557 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -352,6 +451,11 @@ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; }; + 33EBD3AD26728EA70013E557 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 33EBD3AC26728EA70013E557 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -606,6 +710,63 @@ }; name = Release; }; + 33EBD3AE26728EA70013E557 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B43E5DCF2F998ABCD395373 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/path_provider_example.app/Contents/MacOS/path_provider_example"; + }; + name = Debug; + }; + 33EBD3AF26728EA70013E557 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B41979101786837FC1ABC29 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/path_provider_example.app/Contents/MacOS/path_provider_example"; + }; + name = Release; + }; + 33EBD3B026728EA70013E557 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1C62AF358280E9A8FA10B127 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/path_provider_example.app/Contents/MacOS/path_provider_example"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -639,6 +800,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 33EBD3B126728EA70013E557 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33EBD3AE26728EA70013E557 /* Debug */, + 33EBD3AF26728EA70013E557 /* Release */, + 33EBD3B026728EA70013E557 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; diff --git a/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 1552901c04e0..a0f91afed8ea 100644 --- a/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/path_provider/path_provider_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,6 +27,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -38,18 +47,17 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + - - - - - - - - + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/path_provider/path_provider_macos/example/macos/RunnerTests/RunnerTests.swift b/packages/path_provider/path_provider_macos/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000000..35704cdb06d8 --- /dev/null +++ b/packages/path_provider/path_provider_macos/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,102 @@ +// Copyright 2013 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 FlutterMacOS +import XCTest +import path_provider_macos + +class RunnerTests: XCTestCase { + func testGetTemporaryDirectory() throws { + let plugin = PathProviderPlugin() + var path: String? + plugin.handle( + FlutterMethodCall(methodName: "getTemporaryDirectory", arguments: nil), + result: { (result: Any?) -> Void in + path = result as? String + + }) + XCTAssertEqual( + path, + NSSearchPathForDirectoriesInDomains( + FileManager.SearchPathDirectory.cachesDirectory, + FileManager.SearchPathDomainMask.userDomainMask, + true + ).first) + } + + func testGetApplicationDocumentsDirectory() throws { + let plugin = PathProviderPlugin() + var path: String? + plugin.handle( + FlutterMethodCall(methodName: "getApplicationDocumentsDirectory", arguments: nil), + result: { (result: Any?) -> Void in + path = result as? String + + }) + XCTAssertEqual( + path, + NSSearchPathForDirectoriesInDomains( + FileManager.SearchPathDirectory.documentDirectory, + FileManager.SearchPathDomainMask.userDomainMask, + true + ).first) + } + + func testGetApplicationSupportDirectory() throws { + let plugin = PathProviderPlugin() + var path: String? + plugin.handle( + FlutterMethodCall(methodName: "getApplicationSupportDirectory", arguments: nil), + result: { (result: Any?) -> Void in + path = result as? String + + }) + // The application support directory path should be the system application support + // path with an added subdirectory based on the app name. + XCTAssert( + path!.hasPrefix( + NSSearchPathForDirectoriesInDomains( + FileManager.SearchPathDirectory.applicationSupportDirectory, + FileManager.SearchPathDomainMask.userDomainMask, + true + ).first!)) + XCTAssert(path!.hasSuffix("Example")) + } + + func testGetLibraryDirectory() throws { + let plugin = PathProviderPlugin() + var path: String? + plugin.handle( + FlutterMethodCall(methodName: "getLibraryDirectory", arguments: nil), + result: { (result: Any?) -> Void in + path = result as? String + + }) + XCTAssertEqual( + path, + NSSearchPathForDirectoriesInDomains( + FileManager.SearchPathDirectory.libraryDirectory, + FileManager.SearchPathDomainMask.userDomainMask, + true + ).first) + } + + func testGetDownloadsDirectory() throws { + let plugin = PathProviderPlugin() + var path: String? + plugin.handle( + FlutterMethodCall(methodName: "getDownloadsDirectory", arguments: nil), + result: { (result: Any?) -> Void in + path = result as? String + + }) + XCTAssertEqual( + path, + NSSearchPathForDirectoriesInDomains( + FileManager.SearchPathDirectory.downloadsDirectory, + FileManager.SearchPathDomainMask.userDomainMask, + true + ).first) + } +} diff --git a/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md b/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md index ac8dd2c4752b..d5ace31073ad 100644 --- a/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add native unit tests. + ## 2.0.1 * Add `implements` to the pubspec. diff --git a/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile b/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile index dade8dfad0dc..e8da8332969a 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile +++ b/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile @@ -31,6 +31,10 @@ target 'Runner' do use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/project.pbxproj b/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/project.pbxproj index 20c47b4f601f..96f46f062f91 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,11 +21,13 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 2664C8CF4F7C09B469256E8C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBE52A82BBDAFEA0EB8C219A /* Pods_RunnerTests.framework */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 33EBD39B26727BD10013E557 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33EBD39A26727BD10013E557 /* RunnerTests.swift */; }; DD4A1B9DEDBB72C87CD7AE27 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5067D74CB28D28AE3B3DD05B /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -37,6 +39,13 @@ remoteGlobalIDString = 33CC111A2044C6BA0003C045; remoteInfo = FLX; }; + 33EBD39D26727BD10013E557 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -53,6 +62,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2E4DBB55AB946A7F1AA7D737 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; 33CC10ED2044A3C60003C045 /* url_launcher_example_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = url_launcher_example_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -67,12 +77,18 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 33EBD39826727BD10013E557 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 33EBD39A26727BD10013E557 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 33EBD39C26727BD10013E557 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5067D74CB28D28AE3B3DD05B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 53F020549CA1E801ACA3428F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 899489AD6AA35AECA4E2BEA6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + AD26B2A2C7409B621A8ADDA0 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; B36FDC1D769C9045B8821207 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + CC4DAF1C0735E2069209EED8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + FBE52A82BBDAFEA0EB8C219A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -84,6 +100,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD39526727BD10013E557 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2664C8CF4F7C09B469256E8C /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -103,6 +127,7 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 33EBD39926727BD10013E557 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 96C1F6D923BD5787E8EBE8FC /* Pods */, @@ -113,6 +138,7 @@ isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* url_launcher_example_example.app */, + 33EBD39826727BD10013E557 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -139,6 +165,15 @@ path = Flutter; sourceTree = ""; }; + 33EBD39926727BD10013E557 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 33EBD39A26727BD10013E557 /* RunnerTests.swift */, + 33EBD39C26727BD10013E557 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; 33FAB671232836740065AC1E /* Runner */ = { isa = PBXGroup; children = ( @@ -158,8 +193,10 @@ 899489AD6AA35AECA4E2BEA6 /* Pods-Runner.debug.xcconfig */, B36FDC1D769C9045B8821207 /* Pods-Runner.release.xcconfig */, 53F020549CA1E801ACA3428F /* Pods-Runner.profile.xcconfig */, + AD26B2A2C7409B621A8ADDA0 /* Pods-RunnerTests.debug.xcconfig */, + CC4DAF1C0735E2069209EED8 /* Pods-RunnerTests.release.xcconfig */, + 2E4DBB55AB946A7F1AA7D737 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -167,6 +204,7 @@ isa = PBXGroup; children = ( 5067D74CB28D28AE3B3DD05B /* Pods_Runner.framework */, + FBE52A82BBDAFEA0EB8C219A /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -196,13 +234,32 @@ productReference = 33CC10ED2044A3C60003C045 /* url_launcher_example_example.app */; productType = "com.apple.product-type.application"; }; + 33EBD39726727BD10013E557 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33EBD3A226727BD10013E557 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 057C8B472E54526F53651CE7 /* [CP] Check Pods Manifest.lock */, + 33EBD39426727BD10013E557 /* Sources */, + 33EBD39526727BD10013E557 /* Frameworks */, + 33EBD39626727BD10013E557 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 33EBD39E26727BD10013E557 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 33EBD39826727BD10013E557 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0920; + LastSwiftUpdateCheck = 1250; LastUpgradeCheck = 0930; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { @@ -220,6 +277,10 @@ CreatedOnToolsVersion = 9.2; ProvisioningStyle = Manual; }; + 33EBD39726727BD10013E557 = { + CreatedOnToolsVersion = 12.5; + TestTargetID = 33CC10EC2044A3C60003C045; + }; }; }; buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; @@ -237,6 +298,7 @@ targets = ( 33CC10EC2044A3C60003C045 /* Runner */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + 33EBD39726727BD10013E557 /* RunnerTests */, ); }; /* End PBXProject section */ @@ -251,9 +313,38 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD39626727BD10013E557 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 057C8B472E54526F53651CE7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -344,6 +435,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD39426727BD10013E557 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33EBD39B26727BD10013E557 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -352,6 +451,11 @@ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; }; + 33EBD39E26727BD10013E557 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 33EBD39D26727BD10013E557 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -606,6 +710,63 @@ }; name = Release; }; + 33EBD39F26727BD10013E557 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AD26B2A2C7409B621A8ADDA0 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/url_launcher_example_example.app/Contents/MacOS/url_launcher_example_example"; + }; + name = Debug; + }; + 33EBD3A026727BD10013E557 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CC4DAF1C0735E2069209EED8 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/url_launcher_example_example.app/Contents/MacOS/url_launcher_example_example"; + }; + name = Release; + }; + 33EBD3A126727BD10013E557 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2E4DBB55AB946A7F1AA7D737 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/url_launcher_example_example.app/Contents/MacOS/url_launcher_example_example"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -639,6 +800,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 33EBD3A226727BD10013E557 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33EBD39F26727BD10013E557 /* Debug */, + 33EBD3A026727BD10013E557 /* Release */, + 33EBD3A126727BD10013E557 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; diff --git a/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 660c47db95c3..208a9bafa77a 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/shared_preferences/shared_preferences_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,6 +27,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -38,18 +47,17 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + - - - - - - - - + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/shared_preferences/shared_preferences_macos/example/macos/RunnerTests/RunnerTests.swift b/packages/shared_preferences/shared_preferences_macos/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000000..7da66cbc80df --- /dev/null +++ b/packages/shared_preferences/shared_preferences_macos/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,88 @@ +// Copyright 2013 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 FlutterMacOS +import XCTest +import shared_preferences_macos + +class RunnerTests: XCTestCase { + func testHandlesCommitNoOp() throws { + let plugin = SharedPreferencesPlugin() + let call = FlutterMethodCall(methodName: "commit", arguments: nil) + var called = false + plugin.handle( + call, + result: { (result: Any?) -> Void in + called = true + XCTAssert(result as? Bool == true) + }) + XCTAssert(called) + } + + func testSetAndGet() throws { + let plugin = SharedPreferencesPlugin() + let setCall = FlutterMethodCall( + methodName: "setInt", + arguments: [ + "key": "flutter.foo", + "value": 42, + ]) + plugin.handle( + setCall, + result: { (result: Any?) -> Void in + XCTAssert(result as? Bool == true) + }) + + var value: Int? + plugin.handle( + FlutterMethodCall(methodName: "getAll", arguments: nil), + result: { (result: Any?) -> Void in + if let prefs = result as? [String: Any] { + value = prefs["flutter.foo"] as? Int + } + }) + XCTAssertEqual(value, 42) + } + + func testClear() throws { + let plugin = SharedPreferencesPlugin() + let setCall = FlutterMethodCall( + methodName: "setInt", + arguments: [ + "key": "flutter.foo", + "value": 42, + ]) + plugin.handle(setCall, result: { (result: Any?) -> Void in }) + + // Make sure there is something to clear, so the test can't pass due to a set failure. + let getCall = FlutterMethodCall(methodName: "getAll", arguments: nil) + var value: Int? + plugin.handle( + getCall, + result: { (result: Any?) -> Void in + if let prefs = result as? [String: Any] { + value = prefs["flutter.foo"] as? Int + } + }) + XCTAssertEqual(value, 42) + + // Clear the value. + plugin.handle( + FlutterMethodCall(methodName: "clear", arguments: nil), + result: { (result: Any?) -> Void in + XCTAssert(result as? Bool == true) + }) + + // Get the value again, which should clear |value|. + plugin.handle( + getCall, + result: { (result: Any?) -> Void in + if let prefs = result as? [String: Any] { + value = prefs["flutter.foo"] as? Int + XCTAssert(prefs.isEmpty) + } + }) + XCTAssertEqual(value, nil) + } +} diff --git a/packages/url_launcher/url_launcher_macos/CHANGELOG.md b/packages/url_launcher/url_launcher_macos/CHANGELOG.md index 6b0820fd5588..976f7719329b 100644 --- a/packages/url_launcher/url_launcher_macos/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_macos/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Add native unit tests. + ## 2.0.0 * Migrate to null safety. diff --git a/packages/url_launcher/url_launcher_macos/example/macos/Podfile b/packages/url_launcher/url_launcher_macos/example/macos/Podfile index dade8dfad0dc..e8da8332969a 100644 --- a/packages/url_launcher/url_launcher_macos/example/macos/Podfile +++ b/packages/url_launcher/url_launcher_macos/example/macos/Podfile @@ -31,6 +31,10 @@ target 'Runner' do use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/project.pbxproj b/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/project.pbxproj index a95e62daada1..88c678b4a15d 100644 --- a/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/project.pbxproj @@ -26,10 +26,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; }; - 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - D73912F022F37F9E000D13A0 /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; }; - D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 33EBD3B9267296CB0013E557 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33EBD3B8267296CB0013E557 /* RunnerTests.swift */; }; + B0E8018BA137CF3E1D668F89 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A7581585AB49438450A8105 /* Pods_RunnerTests.framework */; }; DD4A1B9DEDBB72C87CD7AE27 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5067D74CB28D28AE3B3DD05B /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -41,6 +39,13 @@ remoteGlobalIDString = 33CC111A2044C6BA0003C045; remoteInfo = FLX; }; + 33EBD3BB267296CB0013E557 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -50,8 +55,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */, - 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */, ); name = "Bundle Framework"; runOnlyForDeploymentPostprocessing = 0; @@ -59,6 +62,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 220FFDB920A73FF04EA40119 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; 33CC10ED2044A3C60003C045 /* url_launcher_example_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = url_launcher_example_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -70,17 +74,21 @@ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FlutterMacOS.framework; path = Flutter/ephemeral/FlutterMacOS.framework; sourceTree = SOURCE_ROOT; }; 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 33EBD3B6267296CB0013E557 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 33EBD3B8267296CB0013E557 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 33EBD3BA267296CB0013E557 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5067D74CB28D28AE3B3DD05B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 53F020549CA1E801ACA3428F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5A7581585AB49438450A8105 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CFCAA4A883B5A0C4BD62DCF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 899489AD6AA35AECA4E2BEA6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B36FDC1D769C9045B8821207 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - D73912EF22F37F9E000D13A0 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/ephemeral/App.framework; sourceTree = SOURCE_ROOT; }; + FD671DB5E266C257DCC5AD6A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -88,12 +96,18 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D73912F022F37F9E000D13A0 /* App.framework in Frameworks */, - 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */, DD4A1B9DEDBB72C87CD7AE27 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD3B3267296CB0013E557 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B0E8018BA137CF3E1D668F89 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -113,6 +127,7 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 33EBD3B7267296CB0013E557 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 96C1F6D923BD5787E8EBE8FC /* Pods */, @@ -123,6 +138,7 @@ isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* url_launcher_example_example.app */, + 33EBD3B6267296CB0013E557 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -145,12 +161,19 @@ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - D73912EF22F37F9E000D13A0 /* App.framework */, - 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */, ); path = Flutter; sourceTree = ""; }; + 33EBD3B7267296CB0013E557 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 33EBD3B8267296CB0013E557 /* RunnerTests.swift */, + 33EBD3BA267296CB0013E557 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; 33FAB671232836740065AC1E /* Runner */ = { isa = PBXGroup; children = ( @@ -170,8 +193,10 @@ 899489AD6AA35AECA4E2BEA6 /* Pods-Runner.debug.xcconfig */, B36FDC1D769C9045B8821207 /* Pods-Runner.release.xcconfig */, 53F020549CA1E801ACA3428F /* Pods-Runner.profile.xcconfig */, + 220FFDB920A73FF04EA40119 /* Pods-RunnerTests.debug.xcconfig */, + 5CFCAA4A883B5A0C4BD62DCF /* Pods-RunnerTests.release.xcconfig */, + FD671DB5E266C257DCC5AD6A /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -179,6 +204,7 @@ isa = PBXGroup; children = ( 5067D74CB28D28AE3B3DD05B /* Pods_Runner.framework */, + 5A7581585AB49438450A8105 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -208,13 +234,32 @@ productReference = 33CC10ED2044A3C60003C045 /* url_launcher_example_example.app */; productType = "com.apple.product-type.application"; }; + 33EBD3B5267296CB0013E557 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33EBD3C0267296CB0013E557 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 460A36EFD54BEB8122DDAC6D /* [CP] Check Pods Manifest.lock */, + 33EBD3B2267296CB0013E557 /* Sources */, + 33EBD3B3267296CB0013E557 /* Frameworks */, + 33EBD3B4267296CB0013E557 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 33EBD3BC267296CB0013E557 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 33EBD3B6267296CB0013E557 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0920; + LastSwiftUpdateCheck = 1250; LastUpgradeCheck = 0930; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { @@ -232,6 +277,10 @@ CreatedOnToolsVersion = 9.2; ProvisioningStyle = Manual; }; + 33EBD3B5267296CB0013E557 = { + CreatedOnToolsVersion = 12.5; + TestTargetID = 33CC10EC2044A3C60003C045; + }; }; }; buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; @@ -249,6 +298,7 @@ targets = ( 33CC10EC2044A3C60003C045 /* Runner */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + 33EBD3B5267296CB0013E557 /* RunnerTests */, ); }; /* End PBXProject section */ @@ -263,6 +313,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD3B4267296CB0013E557 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -281,7 +338,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename\n"; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; }; 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -303,16 +360,41 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh\ntouch Flutter/ephemeral/tripwire\n"; }; - 50C74DCD840D9B569BE3D48F /* [CP] Embed Pods Frameworks */ = { + 460A36EFD54BEB8122DDAC6D /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 50C74DCD840D9B569BE3D48F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/url_launcher_macos/url_launcher_macos.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_macos.framework", + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -353,6 +435,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 33EBD3B2267296CB0013E557 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33EBD3B9267296CB0013E557 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -361,6 +451,11 @@ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; }; + 33EBD3BC267296CB0013E557 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 33EBD3BB267296CB0013E557 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -615,6 +710,63 @@ }; name = Release; }; + 33EBD3BD267296CB0013E557 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 220FFDB920A73FF04EA40119 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/url_launcher_example_example.app/Contents/MacOS/url_launcher_example_example"; + }; + name = Debug; + }; + 33EBD3BE267296CB0013E557 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5CFCAA4A883B5A0C4BD62DCF /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/url_launcher_example_example.app/Contents/MacOS/url_launcher_example_example"; + }; + name = Release; + }; + 33EBD3BF267296CB0013E557 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FD671DB5E266C257DCC5AD6A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/url_launcher_example_example.app/Contents/MacOS/url_launcher_example_example"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -648,6 +800,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 33EBD3C0267296CB0013E557 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33EBD3BD267296CB0013E557 /* Debug */, + 33EBD3BE267296CB0013E557 /* Release */, + 33EBD3BF267296CB0013E557 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; diff --git a/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 660c47db95c3..323d07b817b1 100644 --- a/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/url_launcher/url_launcher_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,6 +27,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -38,18 +47,17 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + - - - - - - - - + + diff --git a/packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/Info.plist b/packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/RunnerTests.swift b/packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000000..d08f66464454 --- /dev/null +++ b/packages/url_launcher/url_launcher_macos/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,24 @@ +// Copyright 2013 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 FlutterMacOS +import XCTest +import url_launcher_macos + +class RunnerTests: XCTestCase { + func testCanLaunch() throws { + let plugin = UrlLauncherPlugin() + let call = FlutterMethodCall( + methodName: "canLaunch", + arguments: ["url": "https://flutter.dev"]) + var canLaunch: Bool? + plugin.handle( + call, + result: { (result: Any?) -> Void in + canLaunch = result as? Bool + }) + + XCTAssertTrue(canLaunch == true) + } +} diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 2ada2cc30cbe..6b3d96b4f35e 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -3,6 +3,8 @@ - Add a --build-id flag to `firebase-test-lab` instead of hard-coding the use of `CIRRUS_BUILD_ID`. `CIRRUS_BUILD_ID` is the default value for that flag, for backward compatibility. +- `xctest` now supports running macOS tests in addition to iOS + - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. ## 0.2.0 diff --git a/script/tool/README.md b/script/tool/README.md index 8ff33a807b81..c0bcd7c5e102 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -79,7 +79,10 @@ dart run ./script/tool/lib/src/main.dart test --plugins plugin_name ```sh cd -dart run ./script/tool/lib/src/main.dart xctest --plugins plugin_name +# For iOS: +dart run ./script/tool/lib/src/main.dart xctest --ios --plugins plugin_name +# For macOS: +dart run ./script/tool/lib/src/main.dart xctest --macos --plugins plugin_name ``` ### Publish a Release diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 82fb12e70d47..9590aecef98e 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -11,6 +11,12 @@ import 'package:platform/platform.dart'; import 'common.dart'; +/// Key for IPA. +const String kIpa = 'ipa'; + +/// Key for APK. +const String kApk = 'apk'; + /// A command to build the example applications for packages. class BuildExamplesCommand extends PluginCommand { /// Creates an instance of the build command. @@ -18,10 +24,10 @@ class BuildExamplesCommand extends PluginCommand { Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), }) : super(packagesDir, processRunner: processRunner) { - argParser.addFlag(kLinux, defaultsTo: false); - argParser.addFlag(kMacos, defaultsTo: false); - argParser.addFlag(kWeb, defaultsTo: false); - argParser.addFlag(kWindows, defaultsTo: false); + argParser.addFlag(kPlatformFlagLinux, defaultsTo: false); + argParser.addFlag(kPlatformFlagMacos, defaultsTo: false); + argParser.addFlag(kPlatformFlagWeb, defaultsTo: false); + argParser.addFlag(kPlatformFlagWindows, defaultsTo: false); argParser.addFlag(kIpa, defaultsTo: io.Platform.isMacOS); argParser.addFlag(kApk); argParser.addOption( @@ -44,10 +50,10 @@ class BuildExamplesCommand extends PluginCommand { final List platformSwitches = [ kApk, kIpa, - kLinux, - kMacos, - kWeb, - kWindows, + kPlatformFlagLinux, + kPlatformFlagMacos, + kPlatformFlagWeb, + kPlatformFlagWindows, ]; if (!platformSwitches.any((String platform) => getBoolArg(platform))) { print( @@ -66,14 +72,14 @@ class BuildExamplesCommand extends PluginCommand { final String packageName = p.relative(example.path, from: packagesDir.path); - if (getBoolArg(kLinux)) { + if (getBoolArg(kPlatformFlagLinux)) { print('\nBUILDING Linux for $packageName'); if (isLinuxPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kLinux, + kPlatformFlagLinux, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], @@ -86,14 +92,14 @@ class BuildExamplesCommand extends PluginCommand { } } - if (getBoolArg(kMacos)) { + if (getBoolArg(kPlatformFlagMacos)) { print('\nBUILDING macOS for $packageName'); if (isMacOsPlugin(plugin)) { final int exitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kMacos, + kPlatformFlagMacos, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], @@ -106,14 +112,14 @@ class BuildExamplesCommand extends PluginCommand { } } - if (getBoolArg(kWeb)) { + if (getBoolArg(kPlatformFlagWeb)) { print('\nBUILDING web for $packageName'); if (isWebPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kWeb, + kPlatformFlagWeb, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], @@ -126,14 +132,14 @@ class BuildExamplesCommand extends PluginCommand { } } - if (getBoolArg(kWindows)) { + if (getBoolArg(kPlatformFlagWindows)) { print('\nBUILDING Windows for $packageName'); if (isWindowsPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kWindows, + kPlatformFlagWindows, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart index f58290b5e07a..20e8479e6d45 100644 --- a/script/tool/lib/src/common.dart +++ b/script/tool/lib/src/common.dart @@ -21,28 +21,22 @@ import 'package:yaml/yaml.dart'; typedef Print = void Function(Object? object); /// Key for windows platform. -const String kWindows = 'windows'; +const String kPlatformFlagWindows = 'windows'; /// Key for macos platform. -const String kMacos = 'macos'; +const String kPlatformFlagMacos = 'macos'; /// Key for linux platform. -const String kLinux = 'linux'; +const String kPlatformFlagLinux = 'linux'; /// Key for IPA (iOS) platform. -const String kIos = 'ios'; +const String kPlatformFlagIos = 'ios'; /// Key for APK (Android) platform. -const String kAndroid = 'android'; +const String kPlatformFlagAndroid = 'android'; /// Key for Web platform. -const String kWeb = 'web'; - -/// Key for IPA. -const String kIpa = 'ipa'; - -/// Key for APK. -const String kApk = 'apk'; +const String kPlatformFlagWeb = 'web'; /// Key for enable experiment. const String kEnableExperiment = 'enable-experiment'; @@ -69,6 +63,15 @@ bool isFlutterPackage(FileSystemEntity entity) { } } +/// Possible plugin support options for a platform. +enum PlatformSupport { + /// The platform has an implementation in the package. + inline, + + /// The platform has an endorsed federated implementation in another package. + federated, +} + /// Returns whether the given directory contains a Flutter [platform] plugin. /// /// It checks this by looking for the following pattern in the pubspec: @@ -77,13 +80,17 @@ bool isFlutterPackage(FileSystemEntity entity) { /// plugin: /// platforms: /// [platform]: -bool pluginSupportsPlatform(String platform, FileSystemEntity entity) { - assert(platform == kIos || - platform == kAndroid || - platform == kWeb || - platform == kMacos || - platform == kWindows || - platform == kLinux); +/// +/// If [requiredMode] is provided, the plugin must have the given type of +/// implementation in order to return true. +bool pluginSupportsPlatform(String platform, FileSystemEntity entity, + {PlatformSupport? requiredMode}) { + assert(platform == kPlatformFlagIos || + platform == kPlatformFlagAndroid || + platform == kPlatformFlagWeb || + platform == kPlatformFlagMacos || + platform == kPlatformFlagWindows || + platform == kPlatformFlagLinux); if (entity is! Directory) { return false; } @@ -102,13 +109,25 @@ bool pluginSupportsPlatform(String platform, FileSystemEntity entity) { } final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; if (platforms == null) { - // Legacy plugin specs are assumed to support iOS and Android. + // Legacy plugin specs are assumed to support iOS and Android. They are + // never federated. + if (requiredMode == PlatformSupport.federated) { + return false; + } if (!pluginSection.containsKey('platforms')) { - return platform == kIos || platform == kAndroid; + return platform == kPlatformFlagIos || platform == kPlatformFlagAndroid; } return false; } - return platforms.containsKey(platform); + final YamlMap? platformEntry = platforms[platform] as YamlMap?; + if (platformEntry == null) { + return false; + } + // If the platform entry is present, then it supports the platform. Check + // for required mode if specified. + final bool federated = platformEntry.containsKey('default_package'); + return requiredMode == null || + federated == (requiredMode == PlatformSupport.federated); } on FileSystemException { return false; } on YamlException { @@ -118,32 +137,32 @@ bool pluginSupportsPlatform(String platform, FileSystemEntity entity) { /// Returns whether the given directory contains a Flutter Android plugin. bool isAndroidPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kAndroid, entity); + return pluginSupportsPlatform(kPlatformFlagAndroid, entity); } /// Returns whether the given directory contains a Flutter iOS plugin. bool isIosPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kIos, entity); + return pluginSupportsPlatform(kPlatformFlagIos, entity); } /// Returns whether the given directory contains a Flutter web plugin. bool isWebPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kWeb, entity); + return pluginSupportsPlatform(kPlatformFlagWeb, entity); } /// Returns whether the given directory contains a Flutter Windows plugin. bool isWindowsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kWindows, entity); + return pluginSupportsPlatform(kPlatformFlagWindows, entity); } /// Returns whether the given directory contains a Flutter macOS plugin. bool isMacOsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kMacos, entity); + return pluginSupportsPlatform(kPlatformFlagMacos, entity); } /// Returns whether the given directory contains a Flutter linux plugin. bool isLinuxPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kLinux, entity); + return pluginSupportsPlatform(kPlatformFlagLinux, entity); } /// Throws a [ToolExit] with `exitCode` and log the `errorMessage` in red. diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 4678a9de3f18..14dfede5b2f1 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -15,17 +15,17 @@ class DriveExamplesCommand extends PluginCommand { Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), }) : super(packagesDir, processRunner: processRunner) { - argParser.addFlag(kAndroid, + argParser.addFlag(kPlatformFlagAndroid, help: 'Runs the Android implementation of the examples'); - argParser.addFlag(kIos, + argParser.addFlag(kPlatformFlagIos, help: 'Runs the iOS implementation of the examples'); - argParser.addFlag(kLinux, + argParser.addFlag(kPlatformFlagLinux, help: 'Runs the Linux implementation of the examples'); - argParser.addFlag(kMacos, + argParser.addFlag(kPlatformFlagMacos, help: 'Runs the macOS implementation of the examples'); - argParser.addFlag(kWeb, + argParser.addFlag(kPlatformFlagWeb, help: 'Runs the web implementation of the examples'); - argParser.addFlag(kWindows, + argParser.addFlag(kPlatformFlagWindows, help: 'Runs the Windows implementation of the examples'); argParser.addOption( kEnableExperiment, @@ -52,10 +52,10 @@ class DriveExamplesCommand extends PluginCommand { Future run() async { final List failingTests = []; final List pluginsWithoutTests = []; - final bool isLinux = getBoolArg(kLinux); - final bool isMacos = getBoolArg(kMacos); - final bool isWeb = getBoolArg(kWeb); - final bool isWindows = getBoolArg(kWindows); + final bool isLinux = getBoolArg(kPlatformFlagLinux); + final bool isMacos = getBoolArg(kPlatformFlagMacos); + final bool isWeb = getBoolArg(kPlatformFlagWeb); + final bool isWindows = getBoolArg(kPlatformFlagWindows); await for (final Directory plugin in getPlugins()) { final String pluginName = plugin.basename; if (pluginName.endsWith('_platform_interface') && @@ -219,12 +219,12 @@ Tried searching for the following: Future _pluginSupportedOnCurrentPlatform( FileSystemEntity plugin) async { - final bool isAndroid = getBoolArg(kAndroid); - final bool isIOS = getBoolArg(kIos); - final bool isLinux = getBoolArg(kLinux); - final bool isMacos = getBoolArg(kMacos); - final bool isWeb = getBoolArg(kWeb); - final bool isWindows = getBoolArg(kWindows); + final bool isAndroid = getBoolArg(kPlatformFlagAndroid); + final bool isIOS = getBoolArg(kPlatformFlagIos); + final bool isLinux = getBoolArg(kPlatformFlagLinux); + final bool isMacos = getBoolArg(kPlatformFlagMacos); + final bool isWeb = getBoolArg(kPlatformFlagWeb); + final bool isWindows = getBoolArg(kPlatformFlagWindows); if (isAndroid) { return isAndroidPlugin(plugin); } diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 1b157ce1ae2d..288851ca7edf 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -17,8 +17,10 @@ const String _kXCRunCommand = 'xcrun'; const String _kFoundNoSimulatorsMessage = 'Cannot find any available simulators, tests failed'; -/// The command to run iOS XCTests in plugins, this should work for both XCUnitTest and XCUITest targets. -/// The tests target have to be added to the xcode project of the example app. Usually at "example/ios/Runner.xcworkspace". +/// The command to run XCTests (XCUnitTest and XCUITest) in plugins. +/// The tests target have to be added to the Xcode project of the example app, +/// usually at "example/{ios,macos}/Runner.xcworkspace". +/// /// The static analyzer is also run. class XCTestCommand extends PluginCommand { /// Creates an instance of the test command. @@ -33,52 +35,61 @@ class XCTestCommand extends PluginCommand { 'this is passed to the `-destination` argument in xcodebuild command.\n' 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.', ); + argParser.addFlag(kPlatformFlagIos, help: 'Runs the iOS tests'); + argParser.addFlag(kPlatformFlagMacos, help: 'Runs the macOS tests'); } @override final String name = 'xctest'; @override - final String description = 'Runs the xctests in the iOS example apps.\n\n' + final String description = + 'Runs the xctests in the iOS and/or macOS example apps.\n\n' 'This command requires "flutter" and "xcrun" to be in your path.'; @override Future run() async { - String destination = getStringArg(_kiOSDestination); - if (destination.isEmpty) { - final String? simulatorId = await _findAvailableIphoneSimulator(); - if (simulatorId == null) { - print(_kFoundNoSimulatorsMessage); - throw ToolExit(1); + final bool testIos = getBoolArg(kPlatformFlagIos); + final bool testMacos = getBoolArg(kPlatformFlagMacos); + + if (!(testIos || testMacos)) { + print('At least one platform flag must be provided.'); + throw ToolExit(2); + } + + List iosDestinationFlags = []; + if (testIos) { + String destination = getStringArg(_kiOSDestination); + if (destination.isEmpty) { + final String? simulatorId = await _findAvailableIphoneSimulator(); + if (simulatorId == null) { + print(_kFoundNoSimulatorsMessage); + throw ToolExit(1); + } + destination = 'id=$simulatorId'; } - destination = 'id=$simulatorId'; + iosDestinationFlags = [ + '-destination', + destination, + ]; } final List failingPackages = []; await for (final Directory plugin in getPlugins()) { - // Start running for package. final String packageName = p.relative(plugin.path, from: packagesDir.path); - print('Start running for $packageName ...'); - if (!isIosPlugin(plugin)) { - print('iOS is not supported by this plugin.'); - print('\n\n'); - continue; + print('============================================================'); + print('Start running for $packageName...'); + bool passed = true; + if (testIos) { + passed &= await _testPlugin(plugin, 'iOS', + extraXcrunFlags: iosDestinationFlags); } - for (final Directory example in getExamplesForPlugin(plugin)) { - // Running tests and static analyzer. - print('Running tests and analyzer for $packageName ...'); - int exitCode = await _runTests(true, destination, example); - // 66 = there is no test target (this fails fast). Try again with just the analyzer. - if (exitCode == 66) { - print('Tests not found for $packageName, running analyzer only...'); - exitCode = await _runTests(false, destination, example); - } - if (exitCode == 0) { - print('Successfully ran xctest for $packageName'); - } else { - failingPackages.add(packageName); - } + if (testMacos) { + passed &= await _testPlugin(plugin, 'macOS'); + } + if (!passed) { + failingPackages.add(packageName); } } @@ -95,19 +106,59 @@ class XCTestCommand extends PluginCommand { } } - Future _runTests(bool runTests, String destination, Directory example) { + /// Runs all applicable tests for [plugin], printing status and returning + /// success if the tests passed (or did not exist). + Future _testPlugin( + Directory plugin, + String platform, { + List extraXcrunFlags = const [], + }) async { + if (!pluginSupportsPlatform(platform.toLowerCase(), plugin, + requiredMode: PlatformSupport.inline)) { + print('$platform is not implemented by this plugin package.'); + print('\n'); + return true; + } + bool passing = true; + for (final Directory example in getExamplesForPlugin(plugin)) { + // Running tests and static analyzer. + final String examplePath = + p.relative(example.path, from: plugin.parent.path); + print('Running $platform tests and analyzer for $examplePath...'); + int exitCode = + await _runTests(true, example, platform, extraFlags: extraXcrunFlags); + // 66 = there is no test target (this fails fast). Try again with just the analyzer. + if (exitCode == 66) { + print('Tests not found for $examplePath, running analyzer only...'); + exitCode = await _runTests(false, example, platform, + extraFlags: extraXcrunFlags); + } + if (exitCode == 0) { + print('Successfully ran $platform xctest for $examplePath'); + } else { + passing = false; + } + } + return passing; + } + + Future _runTests( + bool runTests, + Directory example, + String platform, { + List extraFlags = const [], + }) { final List xctestArgs = [ _kXcodeBuildCommand, if (runTests) 'test', 'analyze', '-workspace', - 'ios/Runner.xcworkspace', + '${platform.toLowerCase()}/Runner.xcworkspace', '-configuration', 'Debug', '-scheme', 'Runner', - '-destination', - destination, + ...extraFlags, 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', ]; final String completeTestCommand = diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart index 2f497963d229..0516fab1da37 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common_test.dart @@ -65,258 +65,301 @@ void main() { runner.addCommand(samplePluginCommand); }); - test('all plugins from file system', () async { - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run(['sample']); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); - - test('all plugins includes third_party/packages', () async { - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - final Directory plugin3 = - createFakePlugin('plugin3', thirdPartyPackagesDir); - await runner.run(['sample']); - expect(plugins, - unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); - }); - - test('exclude plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']); - expect(plugins, unorderedEquals([plugin2.path])); - }); - - test('exclude plugins when plugins flag isn\'t specified', () async { - createFakePlugin('plugin1', packagesDir); - createFakePlugin('plugin2', packagesDir); - await runner.run(['sample', '--exclude=plugin1,plugin2']); - expect(plugins, unorderedEquals([])); - }); - - test('exclude federated plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', packagesDir, parentDirectoryName: 'federated'); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ - 'sample', - '--plugins=federated/plugin1,plugin2', - '--exclude=federated/plugin1' - ]); - expect(plugins, unorderedEquals([plugin2.path])); - }); - - test('exclude entire federated plugins when plugins flag is specified', - () async { - createFakePlugin('plugin1', packagesDir, parentDirectoryName: 'federated'); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ - 'sample', - '--plugins=federated/plugin1,plugin2', - '--exclude=federated' - ]); - expect(plugins, unorderedEquals([plugin2.path])); - }); - - group('test run-on-changed-packages', () { - test('all plugins should be tested if there are no changes.', () async { + group('plugin iteration', () { + test('all plugins from file system', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - + await runner.run(['sample']); expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); - test('all plugins should be tested if there are no plugin related changes.', - () async { - gitDiffResponse = 'AUTHORS'; + test('all plugins includes third_party/packages', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + final Directory plugin3 = + createFakePlugin('plugin3', thirdPartyPackagesDir); + await runner.run(['sample']); + expect(plugins, + unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); + }); + + test('exclude plugins when plugins flag is specified', () async { + createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); + ['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']); + expect(plugins, unorderedEquals([plugin2.path])); + }); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + test('exclude plugins when plugins flag isn\'t specified', () async { + createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + await runner.run(['sample', '--exclude=plugin1,plugin2']); + expect(plugins, unorderedEquals([])); }); - test('all plugins should be tested if .cirrus.yml changes.', () async { - gitDiffResponse = ''' -.cirrus.yml -packages/plugin1/CHANGELOG -'''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + test('exclude federated plugins when plugins flag is specified', () async { + createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'federated'); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); + await runner.run([ + 'sample', + '--plugins=federated/plugin1,plugin2', + '--exclude=federated/plugin1' + ]); + expect(plugins, unorderedEquals([plugin2.path])); + }); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + test('exclude entire federated plugins when plugins flag is specified', + () async { + createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'federated'); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--plugins=federated/plugin1,plugin2', + '--exclude=federated' + ]); + expect(plugins, unorderedEquals([plugin2.path])); }); - test('all plugins should be tested if .ci.yaml changes', () async { - gitDiffResponse = ''' -.ci.yaml + group('test run-on-changed-packages', () { + test('all plugins should be tested if there are no changes.', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); + + test( + 'all plugins should be tested if there are no plugin related changes.', + () async { + gitDiffResponse = 'AUTHORS'; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if .cirrus.yml changes.', () async { + gitDiffResponse = ''' +.cirrus.yml packages/plugin1/CHANGELOG '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); + test('all plugins should be tested if .ci.yaml changes', () async { + gitDiffResponse = ''' +.ci.yaml +packages/plugin1/CHANGELOG +'''; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - test('all plugins should be tested if anything in .ci/ changes', () async { - gitDiffResponse = ''' + test('all plugins should be tested if anything in .ci/ changes', + () async { + gitDiffResponse = ''' .ci/Dockerfile packages/plugin1/CHANGELOG '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - test('all plugins should be tested if anything in script changes.', - () async { - gitDiffResponse = ''' + test('all plugins should be tested if anything in script changes.', + () async { + gitDiffResponse = ''' script/tool_runner.sh packages/plugin1/CHANGELOG '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - test('all plugins should be tested if the root analysis options change.', - () async { - gitDiffResponse = ''' + test('all plugins should be tested if the root analysis options change.', + () async { + gitDiffResponse = ''' analysis_options.yaml packages/plugin1/CHANGELOG '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - test('all plugins should be tested if formatting options change.', - () async { - gitDiffResponse = ''' + test('all plugins should be tested if formatting options change.', + () async { + gitDiffResponse = ''' .clang-format packages/plugin1/CHANGELOG '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); - - test('Only changed plugin should be tested.', () async { - gitDiffResponse = 'packages/plugin1/plugin1.dart'; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - expect(plugins, unorderedEquals([plugin1.path])); - }); + test('Only changed plugin should be tested.', () async { + gitDiffResponse = 'packages/plugin1/plugin1.dart'; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path])); + }); - test('multiple files in one plugin should also test the plugin', () async { - gitDiffResponse = ''' + test('multiple files in one plugin should also test the plugin', + () async { + gitDiffResponse = ''' packages/plugin1/plugin1.dart packages/plugin1/ios/plugin1.m '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - createFakePlugin('plugin2', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path])); + }); - test('multiple plugins changed should test all the changed plugins', - () async { - gitDiffResponse = ''' + test('multiple plugins changed should test all the changed plugins', + () async { + gitDiffResponse = ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - createFakePlugin('plugin3', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - test( - 'multiple plugins inside the same plugin group changed should output the plugin group name', - () async { - gitDiffResponse = ''' + test( + 'multiple plugins inside the same plugin group changed should output the plugin group name', + () async { + gitDiffResponse = ''' packages/plugin1/plugin1/plugin1.dart packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart packages/plugin1/plugin1_web/plugin1_web.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'plugin1'); - createFakePlugin('plugin2', packagesDir); - createFakePlugin('plugin3', packagesDir); - await runner.run( - ['sample', '--base-sha=master', '--run-on-changed-packages']); - - expect(plugins, unorderedEquals([plugin1.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'plugin1'); + createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); + await runner.run([ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path])); + }); - test('--plugins flag overrides the behavior of --run-on-changed-packages', - () async { - gitDiffResponse = ''' + test('--plugins flag overrides the behavior of --run-on-changed-packages', + () async { + gitDiffResponse = ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'plugin1'); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - createFakePlugin('plugin3', packagesDir); - await runner.run([ - 'sample', - '--plugins=plugin1,plugin2', - '--base-sha=master', - '--run-on-changed-packages' - ]); - - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); - }); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'plugin1'); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); + await runner.run([ + 'sample', + '--plugins=plugin1,plugin2', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + }); - test('--exclude flag works with --run-on-changed-packages', () async { - gitDiffResponse = ''' + test('--exclude flag works with --run-on-changed-packages', () async { + gitDiffResponse = ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'plugin1'); - createFakePlugin('plugin2', packagesDir); - createFakePlugin('plugin3', packagesDir); - await runner.run([ - 'sample', - '--exclude=plugin2,plugin3', - '--base-sha=master', - '--run-on-changed-packages' - ]); - - expect(plugins, unorderedEquals([plugin1.path])); + final Directory plugin1 = createFakePlugin('plugin1', packagesDir, + parentDirectoryName: 'plugin1'); + createFakePlugin('plugin2', packagesDir); + createFakePlugin('plugin3', packagesDir); + await runner.run([ + 'sample', + '--exclude=plugin2,plugin3', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(plugins, unorderedEquals([plugin1.path])); + }); }); }); @@ -478,6 +521,196 @@ file2/file2.cc expect(response.httpResponse!.body, json.encode(httpResponse)); }); }); + + group('pluginSupportsPlatform', () { + test('no platforms', () async { + final Directory plugin = createFakePlugin('plugin', packagesDir); + + expect(pluginSupportsPlatform('android', plugin), isFalse); + expect(pluginSupportsPlatform('ios', plugin), isFalse); + expect(pluginSupportsPlatform('linux', plugin), isFalse); + expect(pluginSupportsPlatform('macos', plugin), isFalse); + expect(pluginSupportsPlatform('web', plugin), isFalse); + expect(pluginSupportsPlatform('windows', plugin), isFalse); + }); + + test('all platforms', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + isAndroidPlugin: true, + isIosPlugin: true, + isLinuxPlugin: true, + isMacOsPlugin: true, + isWebPlugin: true, + isWindowsPlugin: true, + ); + + expect(pluginSupportsPlatform('android', plugin), isTrue); + expect(pluginSupportsPlatform('ios', plugin), isTrue); + expect(pluginSupportsPlatform('linux', plugin), isTrue); + expect(pluginSupportsPlatform('macos', plugin), isTrue); + expect(pluginSupportsPlatform('web', plugin), isTrue); + expect(pluginSupportsPlatform('windows', plugin), isTrue); + }); + + test('some platforms', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + isAndroidPlugin: true, + isIosPlugin: false, + isLinuxPlugin: true, + isMacOsPlugin: false, + isWebPlugin: true, + isWindowsPlugin: false, + ); + + expect(pluginSupportsPlatform('android', plugin), isTrue); + expect(pluginSupportsPlatform('ios', plugin), isFalse); + expect(pluginSupportsPlatform('linux', plugin), isTrue); + expect(pluginSupportsPlatform('macos', plugin), isFalse); + expect(pluginSupportsPlatform('web', plugin), isTrue); + expect(pluginSupportsPlatform('windows', plugin), isFalse); + }); + + test('inline plugins are only detected as inline', () async { + // createFakePlugin makes non-federated pubspec entries. + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + isAndroidPlugin: true, + isIosPlugin: true, + isLinuxPlugin: true, + isMacOsPlugin: true, + isWebPlugin: true, + isWindowsPlugin: true, + ); + + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + }); + + test('federated plugins are only detected as federated', () async { + const String pluginName = 'plugin'; + final Directory plugin = createFakePlugin( + pluginName, + packagesDir, + isAndroidPlugin: true, + isIosPlugin: true, + isLinuxPlugin: true, + isMacOsPlugin: true, + isWebPlugin: true, + isWindowsPlugin: true, + ); + + createFakePubspec( + plugin, + name: pluginName, + androidSupport: PlatformSupport.federated, + iosSupport: PlatformSupport.federated, + linuxSupport: PlatformSupport.federated, + macosSupport: PlatformSupport.federated, + webSupport: PlatformSupport.federated, + windowsSupport: PlatformSupport.federated, + ); + + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + }); + }); } class SamplePluginCommand extends PluginCommand { diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 6d36031a2643..11d703177d57 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -193,10 +193,8 @@ void main() { final Directory plugin2Dir = createFakePlugin('no_publish_b', packagesDir, includeVersion: true); - createFakePubspec(plugin1Dir, - name: 'no_publish_a', includeVersion: true, version: '0.1.0'); - createFakePubspec(plugin2Dir, - name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + createFakePubspec(plugin1Dir, name: 'no_publish_a', version: '0.1.0'); + createFakePubspec(plugin2Dir, name: 'no_publish_b', version: '0.2.0'); processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), @@ -259,10 +257,8 @@ void main() { final Directory plugin2Dir = createFakePlugin('no_publish_b', packagesDir, includeVersion: true); - createFakePubspec(plugin1Dir, - name: 'no_publish_a', includeVersion: true, version: '0.1.0'); - createFakePubspec(plugin2Dir, - name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + createFakePubspec(plugin1Dir, name: 'no_publish_a', version: '0.1.0'); + createFakePubspec(plugin2Dir, name: 'no_publish_b', version: '0.2.0'); processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), @@ -328,10 +324,8 @@ void main() { final Directory plugin2Dir = createFakePlugin('no_publish_b', packagesDir, includeVersion: true); - createFakePubspec(plugin1Dir, - name: 'no_publish_a', includeVersion: true, version: '0.1.0'); - createFakePubspec(plugin2Dir, - name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + createFakePubspec(plugin1Dir, name: 'no_publish_a', version: '0.1.0'); + createFakePubspec(plugin2Dir, name: 'no_publish_b', version: '0.2.0'); await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); processRunner.processesToReturn.add( diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 570ceb234a84..14987d47a404 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -53,7 +53,7 @@ void main() { pluginDir = createFakePlugin(testPluginName, packagesDir, withSingleExample: false); assert(pluginDir != null && pluginDir.existsSync()); - createFakePubspec(pluginDir, includeVersion: true); + createFakePubspec(pluginDir, version: '0.0.1'); io.Process.runSync('git', ['init'], workingDirectory: testRoot.path); gitDir = await GitDir.fromExisting(testRoot.path); @@ -139,7 +139,7 @@ void main() { }); test('can publish non-flutter package', () async { - createFakePubspec(pluginDir, includeVersion: true, isFlutter: false); + createFakePubspec(pluginDir, version: '0.0.1', isFlutter: false); io.Process.runSync('git', ['init'], workingDirectory: testRoot.path); gitDir = await GitDir.fromExisting(testRoot.path); @@ -435,15 +435,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin1', isFlutter: false, version: '0.0.1'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin2', isFlutter: false, version: '0.0.1'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -477,10 +471,7 @@ void main() { final Directory pluginDir0 = createFakePlugin('plugin0', packagesDir, withSingleExample: true); createFakePubspec(pluginDir0, - name: 'plugin0', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin0', isFlutter: false, version: '0.0.1'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -497,15 +488,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin1', isFlutter: false, version: '0.0.1'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin2', isFlutter: false, version: '0.0.1'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -539,15 +524,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin1', isFlutter: false, version: '0.0.1'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin2', isFlutter: false, version: '0.0.1'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. @@ -586,15 +565,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin1', isFlutter: false, version: '0.0.1'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin2', isFlutter: false, version: '0.0.1'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -675,15 +648,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin1', isFlutter: false, version: '0.0.1'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin2', isFlutter: false, version: '0.0.1'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -761,15 +728,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.2'); + name: 'plugin1', isFlutter: false, version: '0.0.2'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.2'); + name: 'plugin2', isFlutter: false, version: '0.0.2'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -841,15 +802,9 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, withSingleExample: true, parentDirectoryName: 'plugin2'); createFakePubspec(pluginDir1, - name: 'plugin1', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin1', isFlutter: false, version: '0.0.1'); createFakePubspec(pluginDir2, - name: 'plugin2', - includeVersion: true, - isFlutter: false, - version: '0.0.1'); + name: 'plugin2', isFlutter: false, version: '0.0.1'); io.Process.runSync('git', ['init'], workingDirectory: testRoot.path); diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index c9d4ed23d08a..c590d8a4bb04 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -38,6 +38,7 @@ Directory createFakePlugin( List withExamples = const [], List> withExtraFiles = const >[], bool isFlutter = true, + // TODO(stuartmorgan): Change these platform switches to support type enums. bool isAndroidPlugin = false, bool isIosPlugin = false, bool isWebPlugin = false, @@ -62,14 +63,13 @@ Directory createFakePlugin( createFakePubspec(pluginDirectory, name: name, isFlutter: isFlutter, - isAndroidPlugin: isAndroidPlugin, - isIosPlugin: isIosPlugin, - isWebPlugin: isWebPlugin, - isLinuxPlugin: isLinuxPlugin, - isMacOsPlugin: isMacOsPlugin, - isWindowsPlugin: isWindowsPlugin, - includeVersion: includeVersion, - version: version); + androidSupport: isAndroidPlugin ? PlatformSupport.inline : null, + iosSupport: isIosPlugin ? PlatformSupport.inline : null, + webSupport: isWebPlugin ? PlatformSupport.inline : null, + linuxSupport: isLinuxPlugin ? PlatformSupport.inline : null, + macosSupport: isMacOsPlugin ? PlatformSupport.inline : null, + windowsSupport: isWindowsPlugin ? PlatformSupport.inline : null, + version: includeVersion ? version : null); if (includeChangeLog) { createFakeCHANGELOG(pluginDirectory, ''' ## 0.0.1 @@ -81,10 +81,7 @@ Directory createFakePlugin( final Directory exampleDir = pluginDirectory.childDirectory('example') ..createSync(); createFakePubspec(exampleDir, - name: '${name}_example', - isFlutter: isFlutter, - includeVersion: false, - publishTo: 'none'); + name: '${name}_example', isFlutter: isFlutter, publishTo: 'none'); } else if (withExamples.isNotEmpty) { final Directory exampleDir = pluginDirectory.childDirectory('example') ..createSync(); @@ -92,10 +89,7 @@ Directory createFakePlugin( final Directory currentExample = exampleDir.childDirectory(example) ..createSync(); createFakePubspec(currentExample, - name: example, - isFlutter: isFlutter, - includeVersion: false, - publishTo: 'none'); + name: example, isFlutter: isFlutter, publishTo: 'none'); } } @@ -119,15 +113,14 @@ void createFakePubspec( Directory parent, { String name = 'fake_package', bool isFlutter = true, - bool includeVersion = false, - bool isAndroidPlugin = false, - bool isIosPlugin = false, - bool isWebPlugin = false, - bool isLinuxPlugin = false, - bool isMacOsPlugin = false, - bool isWindowsPlugin = false, + PlatformSupport? androidSupport, + PlatformSupport? iosSupport, + PlatformSupport? linuxSupport, + PlatformSupport? macosSupport, + PlatformSupport? webSupport, + PlatformSupport? windowsSupport, String publishTo = 'http://no_pub_server.com', - String version = '0.0.1', + String? version, }) { parent.childFile('pubspec.yaml').createSync(); String yaml = ''' @@ -136,43 +129,23 @@ flutter: plugin: platforms: '''; - if (isAndroidPlugin) { - yaml += ''' - android: - package: io.flutter.plugins.fake - pluginClass: FakePlugin -'''; + if (androidSupport != null) { + yaml += _pluginPlatformSection('android', androidSupport, name); } - if (isIosPlugin) { - yaml += ''' - ios: - pluginClass: FLTFakePlugin -'''; + if (iosSupport != null) { + yaml += _pluginPlatformSection('ios', iosSupport, name); } - if (isWebPlugin) { - yaml += ''' - web: - pluginClass: FakePlugin - fileName: ${name}_web.dart -'''; + if (webSupport != null) { + yaml += _pluginPlatformSection('web', webSupport, name); } - if (isLinuxPlugin) { - yaml += ''' - linux: - pluginClass: FakePlugin -'''; + if (linuxSupport != null) { + yaml += _pluginPlatformSection('linux', linuxSupport, name); } - if (isMacOsPlugin) { - yaml += ''' - macos: - pluginClass: FakePlugin -'''; + if (macosSupport != null) { + yaml += _pluginPlatformSection('macos', macosSupport, name); } - if (isWindowsPlugin) { - yaml += ''' - windows: - pluginClass: FakePlugin -'''; + if (windowsSupport != null) { + yaml += _pluginPlatformSection('windows', windowsSupport, name); } if (isFlutter) { yaml += ''' @@ -181,7 +154,7 @@ dependencies: sdk: flutter '''; } - if (includeVersion) { + if (version != null) { yaml += ''' version: $version '''; @@ -194,6 +167,53 @@ publish_to: $publishTo # Hardcoded safeguard to prevent this from somehow being parent.childFile('pubspec.yaml').writeAsStringSync(yaml); } +String _pluginPlatformSection( + String platform, PlatformSupport type, String packageName) { + if (type == PlatformSupport.federated) { + return ''' + $platform: + default_package: ${packageName}_$platform +'''; + } + switch (platform) { + case 'android': + return ''' + android: + package: io.flutter.plugins.fake + pluginClass: FakePlugin +'''; + case 'ios': + return ''' + ios: + pluginClass: FLTFakePlugin +'''; + case 'linux': + return ''' + linux: + pluginClass: FakePlugin +'''; + case 'macos': + return ''' + macos: + pluginClass: FakePlugin +'''; + case 'web': + return ''' + web: + pluginClass: FakePlugin + fileName: ${packageName}_web.dart +'''; + case 'windows': + return ''' + windows: + pluginClass: FakePlugin +'''; + default: + assert(false); + return ''; + } +} + typedef _ErrorHandler = void Function(Error error); /// Run the command [runner] with the given [args] and return diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index ec76ceba8e77..fd33c21b2d09 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -336,8 +336,7 @@ void main() { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.1'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' @@ -363,8 +362,7 @@ void main() { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.1'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' ## 1.0.2 @@ -398,8 +396,7 @@ The first version listed in CHANGELOG.md is 1.0.2. final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.1'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' ## 1.0.1 @@ -424,8 +421,7 @@ The first version listed in CHANGELOG.md is 1.0.2. final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.0'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.0'); const String changelog = ''' ## 1.0.1 @@ -466,8 +462,7 @@ The first version listed in CHANGELOG.md is 1.0.1. final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.0'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.0'); const String changelog = ''' ## NEXT @@ -495,8 +490,7 @@ The first version listed in CHANGELOG.md is 1.0.1. final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.1'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' ## 1.0.1 @@ -539,8 +533,7 @@ into the new version's release notes. final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, includeChangeLog: true, includeVersion: true); - createFakePubspec(pluginDirectory, - isFlutter: true, includeVersion: true, version: '1.0.1'); + createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' ## NEXT diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index ffe9bf4267ae..8ed8144562c9 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/xctest_command.dart'; import 'package:test/test.dart'; @@ -102,137 +103,267 @@ void main() { runner.addCommand(command); }); - test('skip if ios is not supported', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: false); - - createFakePubspec(pluginDirectory.childDirectory('example'), - isFlutter: true); - - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; - final List output = await runCapturingPrint( - runner, ['xctest', _kDestination, 'foo_destination']); - expect(output, contains('iOS is not supported by this plugin.')); - expect(processRunner.recordedCalls, orderedEquals([])); + test('Fails if no platforms are provided', () async { + expect( + () => runner.run(['xctest']), + throwsA(isA()), + ); }); - test('running with correct destination, exclude 1 plugin', () async { - final Directory pluginDirectory1 = - createFakePlugin('plugin1', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); - final Directory pluginDirectory2 = - createFakePlugin('plugin2', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); - - final Directory pluginExampleDirectory1 = - pluginDirectory1.childDirectory('example'); - createFakePubspec(pluginExampleDirectory1, isFlutter: true); - final Directory pluginExampleDirectory2 = - pluginDirectory2.childDirectory('example'); - createFakePubspec(pluginExampleDirectory2, isFlutter: true); - - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; - final List output = await runCapturingPrint(runner, [ - 'xctest', - _kDestination, - 'foo_destination', - '--exclude', - 'plugin1' - ]); - - expect(output, isNot(contains('Successfully ran xctest for plugin1'))); - expect(output, contains('Successfully ran xctest for plugin2')); + group('iOS', () { + test('skip if iOS is not supported', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: false, + isMacOsPlugin: true); + + createFakePubspec(pluginDirectory.childDirectory('example'), + isFlutter: true); - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - 'analyze', - '-workspace', - 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', - '-scheme', - 'Runner', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + final List output = await runCapturingPrint(runner, + ['xctest', '--ios', _kDestination, 'foo_destination']); + expect( + output, contains('iOS is not implemented by this plugin package.')); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if iOS is implemented in a federated package', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: true); + createFakePubspec(pluginDirectory, + iosSupport: PlatformSupport.federated); + + createFakePubspec(pluginDirectory.childDirectory('example'), + isFlutter: true); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + final List output = await runCapturingPrint(runner, + ['xctest', '--ios', _kDestination, 'foo_destination']); + expect( + output, contains('iOS is not implemented by this plugin package.')); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('running with correct destination, exclude 1 plugin', () async { + final Directory pluginDirectory1 = + createFakePlugin('plugin1', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: true); + final Directory pluginDirectory2 = + createFakePlugin('plugin2', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: true); + + final Directory pluginExampleDirectory1 = + pluginDirectory1.childDirectory('example'); + createFakePubspec(pluginExampleDirectory1, isFlutter: true); + final Directory pluginExampleDirectory2 = + pluginDirectory2.childDirectory('example'); + createFakePubspec(pluginExampleDirectory2, isFlutter: true); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--ios', + _kDestination, + 'foo_destination', + '--exclude', + 'plugin1' + ]); + + expect(output, isNot(contains('Start running for plugin1...'))); + expect(output, contains('Start running for plugin2...')); + expect(output, + contains('Successfully ran iOS xctest for plugin2/example')); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + '-destination', + 'foo_destination', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory2.path), + ])); + }); + + test('Not specifying --ios-destination assigns an available simulator', + () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], ], - pluginExampleDirectory2.path), - ])); + isIosPlugin: true); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + createFakePubspec(pluginExampleDirectory, isFlutter: true); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + final Map schemeCommandResult = { + 'project': { + 'targets': ['bar_scheme', 'foo_scheme'] + } + }; + // For simplicity of the test, we combine all the mock results into a single mock result, each internal command + // will get this result and they should still be able to parse them correctly. + processRunner.resultStdout = + jsonEncode(schemeCommandResult..addAll(_kDeviceListMap)); + await runner.run(['xctest', '--ios']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall( + 'xcrun', ['simctl', 'list', '--json'], null), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + '-destination', + 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); }); - test('Not specifying --ios-destination assigns an available simulator', - () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); - - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; - final Map schemeCommandResult = { - 'project': { - 'targets': ['bar_scheme', 'foo_scheme'] - } - }; - // For simplicity of the test, we combine all the mock results into a single mock result, each internal command - // will get this result and they should still be able to parse them correctly. - processRunner.resultStdout = - jsonEncode(schemeCommandResult..addAll(_kDeviceListMap)); - await runner.run([ - 'xctest', - ]); + group('macOS', () { + test('skip if macOS is not supported', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isIosPlugin: true, + isMacOsPlugin: false); - expect( - processRunner.recordedCalls, - orderedEquals([ - const ProcessCall( - 'xcrun', ['simctl', 'list', '--json'], null), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - 'analyze', - '-workspace', - 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', - '-scheme', - 'Runner', - '-destination', - 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + createFakePubspec(pluginDirectory.childDirectory('example'), + isFlutter: true); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + final List output = await runCapturingPrint(runner, + ['xctest', '--macos', _kDestination, 'foo_destination']); + expect(output, + contains('macOS is not implemented by this plugin package.')); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if macOS is implemented in a federated package', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], ], - pluginExampleDirectory.path), - ])); + isMacOsPlugin: true); + createFakePubspec(pluginDirectory, + macosSupport: PlatformSupport.federated); + + createFakePubspec(pluginDirectory.childDirectory('example'), + isFlutter: true); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + final List output = await runCapturingPrint(runner, + ['xctest', '--macos', _kDestination, 'foo_destination']); + expect(output, + contains('macOS is not implemented by this plugin package.')); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('runs for macOS plugin', () async { + final Directory pluginDirectory1 = + createFakePlugin('plugin', packagesDir, + withExtraFiles: >[ + ['example', 'test'], + ], + isMacOsPlugin: true); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + createFakePubspec(pluginExampleDirectory, isFlutter: true); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--macos', + ]); + + expect(output, + contains('Successfully ran macOS xctest for plugin/example')); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'macos/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); }); }); } From 3ac31f9f8b15a0d1c759884edf64d6ab79401bea Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 11 Jun 2021 12:17:58 -0700 Subject: [PATCH 036/364] Standardize example bundle ID domain (#4040) Uses "dev.flutter" rather than "io.flutter" for example application IDs on most platforms. Does not change Android since that may have more wide-reaching impact. Also updates example copyrights on those platforms to the correct authorship. Does not update versions on CHANGELOGs since this is an irrelevant-to-clients implementation detail of the example apps. Part of https://github.com/flutter/flutter/issues/31336 --- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 8 ++++---- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ .../example/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ .../example/ios/Runner.xcodeproj/project.pbxproj | 8 ++++---- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 8 ++++---- .../example/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/integration_test/package_info_test.dart | 8 ++++---- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 8 ++++---- .../path_provider/example/linux/CMakeLists.txt | 2 +- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../path_provider/example/windows/runner/Runner.rc | 4 ++-- .../path_provider_linux/example/linux/CMakeLists.txt | 2 +- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 8 ++++---- .../shared_preferences/example/linux/CMakeLists.txt | 2 +- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/windows/runner/Runner.rc | 4 ++-- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../url_launcher/example/windows/runner/Runner.rc | 4 ++-- .../example/macos/Runner/Configs/AppInfo.xcconfig | 4 ++-- .../example/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ .../example/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ .../example/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- 37 files changed, 105 insertions(+), 105 deletions(-) diff --git a/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj b/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj index 3abde6d9a3d0..ad48b12527b0 100644 --- a/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.batteryExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.batteryExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -458,7 +458,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.batteryExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.batteryExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index db93d888a300..aead167a5e99 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -421,7 +421,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "io.flutter.plugins.cameraExample.camera-exampleTests"; + PRODUCT_BUNDLE_IDENTIFIER = "dev.flutter.plugins.cameraExample.camera-exampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; @@ -445,7 +445,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "io.flutter.plugins.cameraExample.camera-exampleTests"; + PRODUCT_BUNDLE_IDENTIFIER = "dev.flutter.plugins.cameraExample.camera-exampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; @@ -574,7 +574,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.cameraExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.cameraExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -595,7 +595,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.cameraExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.cameraExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj b/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj index e77d9f454116..b69d2cc24a6f 100644 --- a/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.connectivityExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.connectivityExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -458,7 +458,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.connectivityExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.connectivityExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/connectivity/connectivity/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/connectivity/connectivity/example/macos/Runner/Configs/AppInfo.xcconfig index a95148814518..1a9e76c10a78 100644 --- a/packages/connectivity/connectivity/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/connectivity/connectivity/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = connectivity_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.connectivityExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.connectivityExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors.flutter.plugins. All rights reserved. diff --git a/packages/connectivity/connectivity_macos/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/connectivity/connectivity_macos/example/macos/Runner/Configs/AppInfo.xcconfig index a95148814518..db9bebac4b66 100644 --- a/packages/connectivity/connectivity_macos/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/connectivity/connectivity_macos/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = connectivity_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.connectivityExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.connectivityExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors. All rights reserved. diff --git a/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj b/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj index 1c6c6e8f4d50..60f84800118f 100644 --- a/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.deviceInfoExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.deviceInfoExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -458,7 +458,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.deviceInfoExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.deviceInfoExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 344a824e44f4..2093134f0bfb 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -609,7 +609,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.googleMobileMapsExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.googleMobileMapsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -630,7 +630,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.googleMobileMapsExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.googleMobileMapsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -644,7 +644,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -659,7 +659,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -672,7 +672,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; @@ -685,7 +685,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; diff --git a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj index a3a99abb2d75..143457fc5acb 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.googleSignInExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.googleSignInExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -634,7 +634,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.googleSignInExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.googleSignInExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -647,7 +647,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -661,7 +661,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -673,7 +673,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; @@ -685,7 +685,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; diff --git a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj index ef315658277b..547c2be4f914 100644 --- a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj @@ -624,7 +624,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -638,7 +638,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -816,7 +816,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.imagePickerExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.imagePickerExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -838,7 +838,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.imagePickerExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.imagePickerExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/in_app_purchase/in_app_purchase/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase/example/ios/Runner.xcodeproj/project.pbxproj index 2de5b9bd557f..df13d20ae61d 100644 --- a/packages/in_app_purchase/in_app_purchase/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase/example/ios/Runner.xcodeproj/project.pbxproj @@ -427,7 +427,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.inAppPurchaseExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.inAppPurchaseExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -451,7 +451,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.inAppPurchaseExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.inAppPurchaseExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj index 90a7f3e86830..590b07f0d385 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -561,7 +561,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.inAppPurchaseExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.inAppPurchaseExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -585,7 +585,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.inAppPurchaseExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.inAppPurchaseExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -608,7 +608,7 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -630,7 +630,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; diff --git a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj index d09b09f7cea9..0ee14af546ef 100644 --- a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj @@ -660,7 +660,7 @@ DEVELOPMENT_TEAM = S8QB4VV633; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -675,7 +675,7 @@ DEVELOPMENT_TEAM = S8QB4VV633; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -690,7 +690,7 @@ DEVELOPMENT_TEAM = S8QB4VV633; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; diff --git a/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj b/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj index 9c9597678546..debbb1d06aba 100644 --- a/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj @@ -568,7 +568,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.localAuthExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.localAuthExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -589,7 +589,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.localAuthExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.localAuthExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/package_info/example/integration_test/package_info_test.dart b/packages/package_info/example/integration_test/package_info_test.dart index 10db8d0caf57..ab8f5f38b472 100644 --- a/packages/package_info/example/integration_test/package_info_test.dart +++ b/packages/package_info/example/integration_test/package_info_test.dart @@ -24,12 +24,12 @@ void main() { } else if (Platform.isIOS) { expect(info.appName, 'Package Info Example'); expect(info.buildNumber, '1'); - expect(info.packageName, 'io.flutter.plugins.packageInfoExample'); + expect(info.packageName, 'dev.flutter.plugins.packageInfoExample'); expect(info.version, '1.0'); } else if (Platform.isMacOS) { expect(info.appName, 'Package Info Example'); expect(info.buildNumber, '1'); - expect(info.packageName, 'io.flutter.plugins.packageInfoExample'); + expect(info.packageName, 'dev.flutter.plugins.packageInfoExample'); expect(info.version, '1.0.0'); } else { throw (UnsupportedError('platform not supported')); @@ -49,13 +49,13 @@ void main() { expect(find.text('Package Info Example'), findsOneWidget); expect(find.text('1'), findsOneWidget); expect( - find.text('io.flutter.plugins.packageInfoExample'), findsOneWidget); + find.text('dev.flutter.plugins.packageInfoExample'), findsOneWidget); expect(find.text('1.0'), findsOneWidget); } else if (Platform.isMacOS) { expect(find.text('Package Info Example'), findsOneWidget); expect(find.text('1'), findsOneWidget); expect( - find.text('io.flutter.plugins.packageInfoExample'), findsOneWidget); + find.text('dev.flutter.plugins.packageInfoExample'), findsOneWidget); expect(find.text('1.0.0'), findsOneWidget); } else { throw (UnsupportedError('platform not supported')); diff --git a/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj b/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj index e1325039d44a..dd979713357f 100644 --- a/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.packageInfoExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.packageInfoExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -458,7 +458,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.packageInfoExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.packageInfoExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/package_info/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/package_info/example/macos/Runner/Configs/AppInfo.xcconfig index b5fb184b14ff..4ecd23f86c29 100644 --- a/packages/package_info/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/package_info/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = Package Info Example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.packageInfoExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.packageInfoExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2020 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2020 The Flutter Authors. All rights reserved. diff --git a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj index 31a9592b5dd8..09c902f41869 100644 --- a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj @@ -517,7 +517,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.pathProviderExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.pathProviderExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -538,7 +538,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.pathProviderExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.pathProviderExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -551,7 +551,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -565,7 +565,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; diff --git a/packages/path_provider/path_provider/example/linux/CMakeLists.txt b/packages/path_provider/path_provider/example/linux/CMakeLists.txt index 279007eb351f..70e26b5d1689 100644 --- a/packages/path_provider/path_provider/example/linux/CMakeLists.txt +++ b/packages/path_provider/path_provider/example/linux/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) project(runner LANGUAGES CXX) set(BINARY_NAME "example") -set(APPLICATION_ID "io.flutter.plugins.example") +set(APPLICATION_ID "dev.flutter.plugins.path_provider_example") cmake_policy(SET CMP0063 NEW) diff --git a/packages/path_provider/path_provider/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/path_provider/path_provider/example/macos/Runner/Configs/AppInfo.xcconfig index 477d8d3d133e..2e7fbeebb87e 100644 --- a/packages/path_provider/path_provider/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/path_provider/path_provider/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = path_provider_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.pathProviderExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.pathProviderExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors. All rights reserved. diff --git a/packages/path_provider/path_provider/example/windows/runner/Runner.rc b/packages/path_provider/path_provider/example/windows/runner/Runner.rc index 9d72c23ad76f..dbda44723259 100644 --- a/packages/path_provider/path_provider/example/windows/runner/Runner.rc +++ b/packages/path_provider/path_provider/example/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "io.flutter.plugins" "\0" + VALUE "CompanyName", "Flutter Dev" "\0" VALUE "FileDescription", "A new Flutter project." "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2020 io.flutter.plugins. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2020 The Flutter Authors. All rights reserved." "\0" VALUE "OriginalFilename", "example.exe" "\0" VALUE "ProductName", "example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/packages/path_provider/path_provider_linux/example/linux/CMakeLists.txt b/packages/path_provider/path_provider_linux/example/linux/CMakeLists.txt index 290c3e841e82..4c422c777e94 100644 --- a/packages/path_provider/path_provider_linux/example/linux/CMakeLists.txt +++ b/packages/path_provider/path_provider_linux/example/linux/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) project(runner LANGUAGES CXX) set(BINARY_NAME "example") -set(APPLICATION_ID "com.example.example") +set(APPLICATION_ID "dev.flutter.plugins.path_provider_linux_example") cmake_policy(SET CMP0063 NEW) diff --git a/packages/path_provider/path_provider_macos/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/path_provider/path_provider_macos/example/macos/Runner/Configs/AppInfo.xcconfig index 477d8d3d133e..2e7fbeebb87e 100644 --- a/packages/path_provider/path_provider_macos/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/path_provider/path_provider_macos/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = path_provider_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.pathProviderExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.pathProviderExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors. All rights reserved. diff --git a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj index aca645880e15..ee150598f59b 100644 --- a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj @@ -531,7 +531,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.quickActionsExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.quickActionsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -552,7 +552,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.quickActionsExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.quickActionsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj b/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj index 9abd66cff4d8..2360a4aaf532 100644 --- a/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj @@ -438,7 +438,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.sensorsExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.sensorsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -460,7 +460,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.sensorsExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.sensorsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/share/example/ios/Runner.xcodeproj/project.pbxproj b/packages/share/example/ios/Runner.xcodeproj/project.pbxproj index 956735eafeab..16acd6a7b886 100644 --- a/packages/share/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/share/example/ios/Runner.xcodeproj/project.pbxproj @@ -531,7 +531,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.shareExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.shareExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -552,7 +552,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.shareExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.shareExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj index 3c0b1b7537fc..395e3009aa37 100644 --- a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj @@ -517,7 +517,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.sharedPreferencesExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.sharedPreferencesExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -538,7 +538,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.sharedPreferencesExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.sharedPreferencesExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -551,7 +551,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -565,7 +565,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; diff --git a/packages/shared_preferences/shared_preferences/example/linux/CMakeLists.txt b/packages/shared_preferences/shared_preferences/example/linux/CMakeLists.txt index 279007eb351f..79f729164ee3 100644 --- a/packages/shared_preferences/shared_preferences/example/linux/CMakeLists.txt +++ b/packages/shared_preferences/shared_preferences/example/linux/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) project(runner LANGUAGES CXX) set(BINARY_NAME "example") -set(APPLICATION_ID "io.flutter.plugins.example") +set(APPLICATION_ID "dev.flutter.plugins.shared_preferences_example") cmake_policy(SET CMP0063 NEW) diff --git a/packages/shared_preferences/shared_preferences/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/shared_preferences/shared_preferences/example/macos/Runner/Configs/AppInfo.xcconfig index cccda2e8a262..e82c4235dcf8 100644 --- a/packages/shared_preferences/shared_preferences/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/shared_preferences/shared_preferences/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.example +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.example // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2021 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2021 The Flutter Authors. All rights reserved. diff --git a/packages/shared_preferences/shared_preferences/example/windows/runner/Runner.rc b/packages/shared_preferences/shared_preferences/example/windows/runner/Runner.rc index 9d72c23ad76f..dbda44723259 100644 --- a/packages/shared_preferences/shared_preferences/example/windows/runner/Runner.rc +++ b/packages/shared_preferences/shared_preferences/example/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "io.flutter.plugins" "\0" + VALUE "CompanyName", "Flutter Dev" "\0" VALUE "FileDescription", "A new Flutter project." "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2020 io.flutter.plugins. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2020 The Flutter Authors. All rights reserved." "\0" VALUE "OriginalFilename", "example.exe" "\0" VALUE "ProductName", "example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/packages/shared_preferences/shared_preferences_macos/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/shared_preferences/shared_preferences_macos/example/macos/Runner/Configs/AppInfo.xcconfig index eddfd3e0bab0..f19f849dea77 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/shared_preferences/shared_preferences_macos/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = url_launcher_example_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.urlLauncherExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.urlLauncherExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors. All rights reserved. diff --git a/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj b/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj index 61ad22c06424..ffc37abef072 100644 --- a/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj @@ -591,7 +591,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.urlLauncher; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.urlLauncher; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -612,7 +612,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.urlLauncher; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.urlLauncher; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -626,7 +626,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -641,7 +641,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -654,7 +654,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; @@ -667,7 +667,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; diff --git a/packages/url_launcher/url_launcher/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/url_launcher/url_launcher/example/macos/Runner/Configs/AppInfo.xcconfig index eddfd3e0bab0..f19f849dea77 100644 --- a/packages/url_launcher/url_launcher/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/url_launcher/url_launcher/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = url_launcher_example_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.urlLauncherExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.urlLauncherExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors. All rights reserved. diff --git a/packages/url_launcher/url_launcher/example/windows/runner/Runner.rc b/packages/url_launcher/url_launcher/example/windows/runner/Runner.rc index 9d72c23ad76f..dbda44723259 100644 --- a/packages/url_launcher/url_launcher/example/windows/runner/Runner.rc +++ b/packages/url_launcher/url_launcher/example/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "io.flutter.plugins" "\0" + VALUE "CompanyName", "Flutter Dev" "\0" VALUE "FileDescription", "A new Flutter project." "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2020 io.flutter.plugins. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2020 The Flutter Authors. All rights reserved." "\0" VALUE "OriginalFilename", "example.exe" "\0" VALUE "ProductName", "example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/packages/url_launcher/url_launcher_macos/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/url_launcher/url_launcher_macos/example/macos/Runner/Configs/AppInfo.xcconfig index eddfd3e0bab0..f19f849dea77 100644 --- a/packages/url_launcher/url_launcher_macos/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/url_launcher/url_launcher_macos/example/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = url_launcher_example_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.urlLauncherExample +PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.urlLauncherExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2019 io.flutter.plugins. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2019 The Flutter Authors. All rights reserved. diff --git a/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj b/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj index c953db774e30..7cef313627f0 100644 --- a/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj @@ -590,7 +590,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.videoPlayerExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.videoPlayerExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -611,7 +611,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.videoPlayerExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.videoPlayerExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -623,7 +623,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; @@ -636,7 +636,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; @@ -651,7 +651,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -666,7 +666,7 @@ INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 5a45c7f4bc96..f75e71d1743a 100644 --- a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -479,7 +479,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -493,7 +493,7 @@ CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; @@ -622,7 +622,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.webviewFlutterExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -645,7 +645,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.webviewFlutterExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -658,7 +658,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; @@ -671,7 +671,7 @@ INFOPLIST_FILE = RunnerUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.RunnerUITests; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_TARGET_NAME = Runner; }; diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj index ad2f823b8d6a..e0a688dfffad 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -310,7 +310,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.wifiInfoFlutterExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.wifiInfoFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -438,7 +438,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.wifiInfoFlutterExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.wifiInfoFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -461,7 +461,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.wifiInfoFlutterExample; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.wifiInfoFlutterExample; PRODUCT_NAME = "$(TARGET_NAME)"; VERSIONING_SYSTEM = "apple-generic"; }; From d755126dc082822e22a85a15bfbf73dd8dad6656 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 11 Jun 2021 16:10:41 -0700 Subject: [PATCH 037/364] [flutter_plugin_tools] Migrate publish and version checks to NNBD (#4037) Migrates publish-check and version-check commands to NNBD. Reworks the version-check flow so that it's more consistent with the other commands: instead of immediately exiting on failure, it checks all plugins, gathers failures, and summarizes all failures at the end. This ensures that we don't have failures in one package temporarily masked by failures in another, so PRs don't need to go through as many check cycles. Part of https://github.com/flutter/flutter/issues/81912 --- script/tool/lib/src/common.dart | 33 ++--- .../tool/lib/src/publish_check_command.dart | 29 ++-- .../tool/lib/src/publish_plugin_command.dart | 3 +- .../tool/lib/src/version_check_command.dart | 126 +++++++++++------- script/tool/test/common_test.dart | 16 +-- .../tool/test/publish_check_command_test.dart | 14 +- script/tool/test/version_check_test.dart | 31 +++-- 7 files changed, 134 insertions(+), 118 deletions(-) diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart index 20e8479e6d45..5d653ad0ed20 100644 --- a/script/tool/lib/src/common.dart +++ b/script/tool/lib/src/common.dart @@ -165,11 +165,10 @@ bool isLinuxPlugin(FileSystemEntity entity) { return pluginSupportsPlatform(kPlatformFlagLinux, entity); } -/// Throws a [ToolExit] with `exitCode` and log the `errorMessage` in red. -void printErrorAndExit({required String errorMessage, int exitCode = 1}) { +/// Prints `errorMessage` in red. +void printError(String errorMessage) { final Colorize redError = Colorize(errorMessage)..red(); print(redError); - throw ToolExit(exitCode); } /// Error thrown when a command needs to exit with a non-zero exit code. @@ -456,9 +455,10 @@ abstract class PluginCommand extends Command { GitDir? baseGitDir = gitDir; if (baseGitDir == null) { if (!await GitDir.isGitDir(rootDir)) { - printErrorAndExit( - errorMessage: '$rootDir is not a valid Git repository.', - exitCode: 2); + printError( + '$rootDir is not a valid Git repository.', + ); + throw ToolExit(2); } baseGitDir = await GitDir.fromExisting(rootDir); } @@ -599,7 +599,7 @@ class ProcessRunner { /// passing [workingDir]. /// /// Returns the started [io.Process]. - Future start(String executable, List args, + Future start(String executable, List args, {Directory? workingDirectory}) async { final io.Process process = await io.Process.start(executable, args, workingDirectory: workingDirectory?.path); @@ -641,12 +641,12 @@ class PubVersionFinder { if (response.statusCode == 404) { return PubVersionFinderResponse( - versions: null, + versions: [], result: PubVersionFinderResult.noPackageFound, httpResponse: response); } else if (response.statusCode != 200) { return PubVersionFinderResponse( - versions: null, + versions: [], result: PubVersionFinderResult.fail, httpResponse: response); } @@ -666,9 +666,12 @@ class PubVersionFinder { /// Represents a response for [PubVersionFinder]. class PubVersionFinderResponse { /// Constructor. - PubVersionFinderResponse({this.versions, this.result, this.httpResponse}) { - if (versions != null && versions!.isNotEmpty) { - versions!.sort((Version a, Version b) { + PubVersionFinderResponse( + {required this.versions, + required this.result, + required this.httpResponse}) { + if (versions.isNotEmpty) { + versions.sort((Version a, Version b) { // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize]. // https://github.com/flutter/flutter/issues/82222 return b.compareTo(a); @@ -680,13 +683,13 @@ class PubVersionFinderResponse { /// /// This is sorted by largest to smallest, so the first element in the list is the largest version. /// Might be `null` if the [result] is not [PubVersionFinderResult.success]. - final List? versions; + final List versions; /// The result of the version finder. - final PubVersionFinderResult? result; + final PubVersionFinderResult result; /// The response object of the http request. - final http.Response? httpResponse; + final http.Response httpResponse; } /// An enum representing the result of [PubVersionFinder]. diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index fa229cabefcc..b77eceecbf41 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -11,7 +9,6 @@ import 'dart:io' as io; import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; import 'package:http/http.dart' as http; -import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -23,7 +20,7 @@ class PublishCheckCommand extends PluginCommand { PublishCheckCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - this.httpClient, + http.Client? httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), super(packagesDir, processRunner: processRunner) { @@ -65,9 +62,6 @@ class PublishCheckCommand extends PluginCommand { final String description = 'Checks to make sure that a plugin *could* be published.'; - /// The custom http client used to query versions on pub. - final http.Client httpClient; - final PubVersionFinder _pubVersionFinder; // The output JSON when the _machineFlag is on. @@ -135,7 +129,7 @@ class PublishCheckCommand extends PluginCommand { } } - Pubspec _tryParsePubspec(Directory package) { + Pubspec? _tryParsePubspec(Directory package) { final File pubspecFile = package.childFile('pubspec.yaml'); try { @@ -205,7 +199,7 @@ class PublishCheckCommand extends PluginCommand { final String packageName = package.basename; print('Checking that $packageName can be published.'); - final Pubspec pubspec = _tryParsePubspec(package); + final Pubspec? pubspec = _tryParsePubspec(package); if (pubspec == null) { print('no pubspec'); return _PublishCheckResult._error; @@ -214,7 +208,7 @@ class PublishCheckCommand extends PluginCommand { return _PublishCheckResult._published; } - final Version version = pubspec.version; + final Version? version = pubspec.version; final _PublishCheckResult alreadyPublishedResult = await _checkIfAlreadyPublished( packageName: packageName, version: version); @@ -238,29 +232,24 @@ class PublishCheckCommand extends PluginCommand { // Check if `packageName` already has `version` published on pub. Future<_PublishCheckResult> _checkIfAlreadyPublished( - {String packageName, Version version}) async { + {required String packageName, required Version? version}) async { final PubVersionFinderResponse pubVersionFinderResponse = await _pubVersionFinder.getPackageVersion(package: packageName); - _PublishCheckResult result; switch (pubVersionFinderResponse.result) { case PubVersionFinderResult.success: - result = pubVersionFinderResponse.versions.contains(version) + return pubVersionFinderResponse.versions.contains(version) ? _PublishCheckResult._published : _PublishCheckResult._notPublished; - break; case PubVersionFinderResult.fail: print(''' Error fetching version on pub for $packageName. HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} HTTP response: ${pubVersionFinderResponse.httpResponse.body} '''); - result = _PublishCheckResult._error; - break; + return _PublishCheckResult._error; case PubVersionFinderResult.noPackageFound: - result = _PublishCheckResult._notPublished; - break; + return _PublishCheckResult._notPublished; } - return result; } void _setStatus(String status) { @@ -272,7 +261,7 @@ HTTP response: ${pubVersionFinderResponse.httpResponse.body} return const JsonEncoder.withIndent(' ').convert(_machineOutput); } - void _printImportantStatusMessage(String message, {@required bool isError}) { + void _printImportantStatusMessage(String message, {required bool isError}) { final String statusMessage = '${isError ? 'ERROR' : 'SUCCESS'}: $message'; if (getBoolArg(_machineFlag)) { print(statusMessage); diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 6b837cb3f4fa..6a215064cf5b 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -507,10 +507,11 @@ Safe to ignore if the package is deleted in this commit. } final String credential = io.Platform.environment[_pubCredentialName]; if (credential == null) { - printErrorAndExit(errorMessage: ''' + printError(''' No pub credential available. Please check if `~/.pub-cache/credentials.json` is valid. If running this command on CI, you can set the pub credential content in the $_pubCredentialName environment variable. '''); + throw ToolExit(1); } credentialFile.openSync(mode: FileMode.writeOnlyAppend) ..writeStringSync(credential) diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index d74c7dad3c55..6baa38e465a2 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'package:file/file.dart'; @@ -37,7 +35,7 @@ enum NextVersionType { /// those have different semver rules. @visibleForTesting Map getAllowedNextVersions( - Version masterVersion, Version headVersion) { + {required Version masterVersion, required Version headVersion}) { final Map allowedNextVersions = { masterVersion.nextMajor: NextVersionType.BREAKING_MAJOR, @@ -75,8 +73,8 @@ class VersionCheckCommand extends PluginCommand { VersionCheckCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - GitDir gitDir, - this.httpClient, + GitDir? gitDir, + http.Client? httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), super(packagesDir, processRunner: processRunner, gitDir: gitDir) { @@ -100,9 +98,6 @@ class VersionCheckCommand extends PluginCommand { 'Also checks if the latest version in CHANGELOG matches the version in pubspec.\n\n' 'This command requires "pub" and "flutter" to be in your path.'; - /// The http client used to query pub server. - final http.Client httpClient; - final PubVersionFinder _pubVersionFinder; @override @@ -112,6 +107,8 @@ class VersionCheckCommand extends PluginCommand { final List changedPubspecs = await gitVersionFinder.getChangedPubSpecs(); + final List badVersionChangePubspecs = []; + const String indentation = ' '; for (final String pubspecPath in changedPubspecs) { print('Checking versions for $pubspecPath...'); @@ -126,15 +123,16 @@ class VersionCheckCommand extends PluginCommand { continue; } - final Version headVersion = + final Version? headVersion = await gitVersionFinder.getPackageVersion(pubspecPath, gitRef: 'HEAD'); if (headVersion == null) { - printErrorAndExit( - errorMessage: '${indentation}No version found. A package that ' - 'intentionally has no version should be marked ' - '"publish_to: none".'); + printError('${indentation}No version found. A package that ' + 'intentionally has no version should be marked ' + '"publish_to: none".'); + badVersionChangePubspecs.add(pubspecPath); + continue; } - Version sourceVersion; + Version? sourceVersion; if (getBoolArg(_againstPubFlag)) { final String packageName = pubspecFile.parent.basename; final PubVersionFinderResponse pubVersionFinderResponse = @@ -146,12 +144,13 @@ class VersionCheckCommand extends PluginCommand { '$indentation$packageName: Current largest version on pub: $sourceVersion'); break; case PubVersionFinderResult.fail: - printErrorAndExit(errorMessage: ''' + printError(''' ${indentation}Error fetching version on pub for $packageName. ${indentation}HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} '''); - break; + badVersionChangePubspecs.add(pubspecPath); + continue; case PubVersionFinderResult.noPackageFound: sourceVersion = null; break; @@ -180,7 +179,8 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} // Check for reverts when doing local validation. if (!getBoolArg(_againstPubFlag) && headVersion < sourceVersion) { final Map possibleVersionsFromNewVersion = - getAllowedNextVersions(headVersion, sourceVersion); + getAllowedNextVersions( + masterVersion: headVersion, headVersion: sourceVersion); // Since this skips validation, try to ensure that it really is likely // to be a revert rather than a typo by checking that the transition // from the lower version to the new version would have been valid. @@ -192,14 +192,16 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} } final Map allowedNextVersions = - getAllowedNextVersions(sourceVersion, headVersion); + getAllowedNextVersions( + masterVersion: sourceVersion, headVersion: headVersion); if (!allowedNextVersions.containsKey(headVersion)) { final String source = (getBoolArg(_againstPubFlag)) ? 'pub' : 'master'; - final String error = '${indentation}Incorrectly updated version.\n' + printError('${indentation}Incorrectly updated version.\n' '${indentation}HEAD: $headVersion, $source: $sourceVersion.\n' - '${indentation}Allowed versions: $allowedNextVersions'; - printErrorAndExit(errorMessage: error); + '${indentation}Allowed versions: $allowedNextVersions'); + badVersionChangePubspecs.add(pubspecPath); + continue; } else { print('$indentation$headVersion -> $sourceVersion'); } @@ -208,38 +210,65 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} pubspec.name.endsWith('_platform_interface'); if (isPlatformInterface && allowedNextVersions[headVersion] == NextVersionType.BREAKING_MAJOR) { - final String error = '$pubspecPath breaking change detected.\n' - 'Breaking changes to platform interfaces are strongly discouraged.\n'; - printErrorAndExit(errorMessage: error); + printError('$pubspecPath breaking change detected.\n' + 'Breaking changes to platform interfaces are strongly discouraged.\n'); + badVersionChangePubspecs.add(pubspecPath); + continue; } } + _pubVersionFinder.httpClient.close(); + // TODO(stuartmorgan): Unify the way iteration works for these checks; the + // two checks shouldn't be operating independently on different lists. + final List mismatchedVersionPlugins = []; await for (final Directory plugin in getPlugins()) { - await _checkVersionsMatch(plugin); + if (!(await _checkVersionsMatch(plugin))) { + mismatchedVersionPlugins.add(plugin.basename); + } + } + + bool passed = true; + if (badVersionChangePubspecs.isNotEmpty) { + passed = false; + printError(''' +The following pubspecs failed validaton: +$indentation${badVersionChangePubspecs.join('\n$indentation')} +'''); + } + if (mismatchedVersionPlugins.isNotEmpty) { + passed = false; + printError(''' +The following pubspecs have different versions in pubspec.yaml and CHANGELOG.md: +$indentation${mismatchedVersionPlugins.join('\n$indentation')} +'''); + } + if (!passed) { + throw ToolExit(1); } - _pubVersionFinder.httpClient.close(); print('No version check errors found!'); } - Future _checkVersionsMatch(Directory plugin) async { + /// Returns whether or not the pubspec version and CHANGELOG version for + /// [plugin] match. + Future _checkVersionsMatch(Directory plugin) async { // get version from pubspec final String packageName = plugin.basename; print('-----------------------------------------'); print( 'Checking the first version listed in CHANGELOG.md matches the version in pubspec.yaml for $packageName.'); - final Pubspec pubspec = _tryParsePubspec(plugin); + final Pubspec? pubspec = _tryParsePubspec(plugin); if (pubspec == null) { - const String error = 'Cannot parse version from pubspec.yaml'; - printErrorAndExit(errorMessage: error); + printError('Cannot parse version from pubspec.yaml'); + return false; } - final Version fromPubspec = pubspec.version; + final Version? fromPubspec = pubspec.version; // get first version from CHANGELOG final File changelog = plugin.childFile('CHANGELOG.md'); final List lines = changelog.readAsLinesSync(); - String firstLineWithText; + String? firstLineWithText; final Iterator iterator = lines.iterator; while (iterator.moveNext()) { if (iterator.current.trim().isNotEmpty) { @@ -248,7 +277,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} } } // Remove all leading mark down syntax from the version line. - String versionString = firstLineWithText.split(' ').last; + String? versionString = firstLineWithText?.split(' ').last; // Skip validation for the special NEXT version that's used to accumulate // changes that don't warrant publishing on their own. @@ -266,51 +295,48 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} } } - final Version fromChangeLog = Version.parse(versionString); + final Version? fromChangeLog = + versionString == null ? null : Version.parse(versionString); if (fromChangeLog == null) { - final String error = - 'Cannot find version on the first line of ${plugin.path}/CHANGELOG.md'; - printErrorAndExit(errorMessage: error); + printError( + 'Cannot find version on the first line of ${plugin.path}/CHANGELOG.md'); + return false; } if (fromPubspec != fromChangeLog) { - final String error = ''' + printError(''' versions for $packageName in CHANGELOG.md and pubspec.yaml do not match. The version in pubspec.yaml is $fromPubspec. The first version listed in CHANGELOG.md is $fromChangeLog. -'''; - printErrorAndExit(errorMessage: error); +'''); + return false; } // If NEXT wasn't the first section, it should not exist at all. if (!hasNextSection) { final RegExp nextRegex = RegExp(r'^#+\s*NEXT\s*$'); if (lines.any((String line) => nextRegex.hasMatch(line))) { - printErrorAndExit(errorMessage: ''' + printError(''' When bumping the version for release, the NEXT section should be incorporated into the new version's release notes. '''); + return false; } } print('$packageName passed version check'); + return true; } - Pubspec _tryParsePubspec(Directory package) { + Pubspec? _tryParsePubspec(Directory package) { final File pubspecFile = package.childFile('pubspec.yaml'); try { final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); - if (pubspec == null) { - final String error = - 'Failed to parse `pubspec.yaml` at ${pubspecFile.path}'; - printErrorAndExit(errorMessage: error); - } return pubspec; } on Exception catch (exception) { - final String error = - 'Failed to parse `pubspec.yaml` at ${pubspecFile.path}: $exception}'; - printErrorAndExit(errorMessage: error); + printError( + 'Failed to parse `pubspec.yaml` at ${pubspecFile.path}: $exception}'); } return null; } diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart index 0516fab1da37..a51182d91ff8 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common_test.dart @@ -457,10 +457,10 @@ file2/file2.cc final PubVersionFinderResponse response = await finder.getPackageVersion(package: 'some_package'); - expect(response.versions, isNull); + expect(response.versions, isEmpty); expect(response.result, PubVersionFinderResult.noPackageFound); - expect(response.httpResponse!.statusCode, 404); - expect(response.httpResponse!.body, ''); + expect(response.httpResponse.statusCode, 404); + expect(response.httpResponse.body, ''); }); test('HTTP error when getting versions from pub', () async { @@ -471,10 +471,10 @@ file2/file2.cc final PubVersionFinderResponse response = await finder.getPackageVersion(package: 'some_package'); - expect(response.versions, isNull); + expect(response.versions, isEmpty); expect(response.result, PubVersionFinderResult.fail); - expect(response.httpResponse!.statusCode, 400); - expect(response.httpResponse!.body, ''); + expect(response.httpResponse.statusCode, 400); + expect(response.httpResponse.body, ''); }); test('Get a correct list of versions when http response is OK.', () async { @@ -517,8 +517,8 @@ file2/file2.cc Version.parse('0.0.1'), ]); expect(response.result, PubVersionFinderResult.success); - expect(response.httpResponse!.statusCode, 200); - expect(response.httpResponse!.body, json.encode(httpResponse)); + expect(response.httpResponse.statusCode, 200); + expect(response.httpResponse.body, json.encode(httpResponse)); }); }); diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 11d703177d57..e5722567f20c 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:collection'; import 'dart:convert'; import 'dart:io' as io; @@ -23,9 +21,9 @@ import 'util.dart'; void main() { group('$PublishCheckProcessRunner tests', () { FileSystem fileSystem; - Directory packagesDir; - PublishCheckProcessRunner processRunner; - CommandRunner runner; + late Directory packagesDir; + late PublishCheckProcessRunner processRunner; + late CommandRunner runner; setUp(() { fileSystem = MemoryFileSystem(); @@ -177,7 +175,7 @@ void main() { } else if (request.url.pathSegments.last == 'no_publish_b.json') { return http.Response(json.encode(httpResponseB), 200); } - return null; + return http.Response('', 500); }); final PublishCheckCommand command = PublishCheckCommand(packagesDir, processRunner: processRunner, httpClient: mockClient); @@ -241,7 +239,7 @@ void main() { } else if (request.url.pathSegments.last == 'no_publish_b.json') { return http.Response(json.encode(httpResponseB), 200); } - return null; + return http.Response('', 500); }); final PublishCheckCommand command = PublishCheckCommand(packagesDir, processRunner: processRunner, httpClient: mockClient); @@ -308,7 +306,7 @@ void main() { } else if (request.url.pathSegments.last == 'no_publish_b.json') { return http.Response(json.encode(httpResponseB), 200); } - return null; + return http.Response('', 500); }); final PublishCheckCommand command = PublishCheckCommand(packagesDir, processRunner: processRunner, httpClient: mockClient); diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index fd33c21b2d09..1199c270642f 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -13,24 +11,25 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/version_check_command.dart'; -import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; + +import 'common_test.mocks.dart'; import 'util.dart'; void testAllowedVersion( String masterVersion, String headVersion, { bool allowed = true, - NextVersionType nextVersionType, + NextVersionType? nextVersionType, }) { final Version master = Version.parse(masterVersion); final Version head = Version.parse(headVersion); final Map allowedVersions = - getAllowedNextVersions(master, head); + getAllowedNextVersions(masterVersion: master, headVersion: head); if (allowed) { expect(allowedVersions, contains(head)); if (nextVersionType != null) { @@ -41,8 +40,6 @@ void testAllowedVersion( } } -class MockGitDir extends Mock implements GitDir {} - class MockProcessResult extends Mock implements io.ProcessResult {} const String _redColorMessagePrefix = '\x1B[31m'; @@ -57,13 +54,13 @@ void main() { const String indentation = ' '; group('$VersionCheckCommand', () { FileSystem fileSystem; - Directory packagesDir; - CommandRunner runner; - RecordingProcessRunner processRunner; - List> gitDirCommands; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + late List> gitDirCommands; String gitDiffResponse; Map gitShowResponses; - MockGitDir gitDir; + late MockGitDir gitDir; setUp(() { fileSystem = MemoryFileSystem(); @@ -77,17 +74,19 @@ void main() { gitDirCommands.add(invocation.positionalArguments[0] as List); final MockProcessResult mockProcessResult = MockProcessResult(); if (invocation.positionalArguments[0][0] == 'diff') { - when(mockProcessResult.stdout as String) + when(mockProcessResult.stdout as String?) .thenReturn(gitDiffResponse); } else if (invocation.positionalArguments[0][0] == 'show') { - final String response = + final String? response = gitShowResponses[invocation.positionalArguments[0][1]]; if (response == null) { throw const io.ProcessException('git', ['show']); } - when(mockProcessResult.stdout as String).thenReturn(response); + when(mockProcessResult.stdout as String?) + .thenReturn(response); } else if (invocation.positionalArguments[0][0] == 'merge-base') { - when(mockProcessResult.stdout as String).thenReturn('abc123'); + when(mockProcessResult.stdout as String?) + .thenReturn('abc123'); } return Future.value(mockProcessResult); }); From 01800a04f09cf5e8ec010e3027e5b9809094ee0c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Sat, 12 Jun 2021 12:44:04 -0700 Subject: [PATCH 038/364] [flutter_plugin_tools] Complete migration to NNBD (#4048) --- script/tool/CHANGELOG.md | 1 + script/tool/bin/flutter_plugin_tools.dart | 2 - script/tool/lib/src/main.dart | 2 - .../tool/lib/src/publish_plugin_command.dart | 154 ++++++++++-------- .../test/publish_plugin_command_test.dart | 56 +++---- 5 files changed, 110 insertions(+), 105 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 6b3d96b4f35e..ae81ced63662 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -5,6 +5,7 @@ compatibility. - `xctest` now supports running macOS tests in addition to iOS - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. +- The tooling now runs in strong null-safe mode. ## 0.2.0 diff --git a/script/tool/bin/flutter_plugin_tools.dart b/script/tool/bin/flutter_plugin_tools.dart index eea0db5a7f01..0f30bee0d258 100644 --- a/script/tool/bin/flutter_plugin_tools.dart +++ b/script/tool/bin/flutter_plugin_tools.dart @@ -2,6 +2,4 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - export 'package:flutter_plugin_tools/src/main.dart'; diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index 9f1a1d5bf240..a7603122186a 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:io' as io; import 'package:args/command_runner.dart'; diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 6a215064cf5b..1e7c15029846 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -18,6 +16,17 @@ import 'package:yaml/yaml.dart'; import 'common.dart'; +@immutable +class _RemoteInfo { + const _RemoteInfo({required this.name, required this.url}); + + /// The git name for the remote. + final String name; + + /// The remote's URL. + final String url; +} + /// Wraps pub publish with a few niceties used by the flutter/plugin team. /// /// 1. Checks for any modified files in git and refuses to publish if there's an @@ -35,8 +44,8 @@ class PublishPluginCommand extends PluginCommand { Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Print print = print, - io.Stdin stdinput, - GitDir gitDir, + io.Stdin? stdinput, + GitDir? gitDir, }) : _print = print, _stdin = stdinput ?? io.stdin, super(packagesDir, processRunner: processRunner, gitDir: gitDir) { @@ -118,7 +127,7 @@ class PublishPluginCommand extends PluginCommand { final Print _print; final io.Stdin _stdin; - StreamSubscription _stdinSubscription; + StreamSubscription? _stdinSubscription; @override Future run() async { @@ -138,14 +147,20 @@ class PublishPluginCommand extends PluginCommand { _print('$packagesPath is not a valid Git repository.'); throw ToolExit(1); } - final GitDir baseGitDir = + final GitDir baseGitDir = gitDir ?? await GitDir.fromExisting(packagesPath, allowSubdirectory: true); final bool shouldPushTag = getBoolArg(_pushTagsOption); - final String remote = getStringArg(_remoteOption); - String remoteUrl; + _RemoteInfo? remote; if (shouldPushTag) { - remoteUrl = await _verifyRemote(remote); + final String remoteName = getStringArg(_remoteOption); + final String? remoteUrl = await _verifyRemote(remoteName); + if (remoteUrl == null) { + printError( + 'Unable to find URL for remote $remoteName; cannot push tags'); + throw ToolExit(1); + } + remote = _RemoteInfo(name: remoteName, url: remoteUrl); } _print('Local repo is ready!'); if (getBoolArg(_dryRunFlag)) { @@ -155,27 +170,21 @@ class PublishPluginCommand extends PluginCommand { bool successful; if (publishAllChanged) { successful = await _publishAllChangedPackages( - remote: remote, - remoteUrl: remoteUrl, - shouldPushTag: shouldPushTag, baseGitDir: baseGitDir, + remoteForTagPush: remote, ); } else { successful = await _publishAndTagPackage( packageDir: _getPackageDir(package), - remote: remote, - remoteUrl: remoteUrl, - shouldPushTag: shouldPushTag, + remoteForTagPush: remote, ); } await _finish(successful); } Future _publishAllChangedPackages({ - String remote, - String remoteUrl, - bool shouldPushTag, - GitDir baseGitDir, + required GitDir baseGitDir, + _RemoteInfo? remoteForTagPush, }) async { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); final List changedPubspecs = @@ -215,9 +224,7 @@ class PublishPluginCommand extends PluginCommand { _print('\n'); if (await _publishAndTagPackage( packageDir: pubspecFile.parent, - remote: remote, - remoteUrl: remoteUrl, - shouldPushTag: shouldPushTag, + remoteForTagPush: remoteForTagPush, )) { packagesReleased.add(pubspecFile.parent.basename); } else { @@ -237,13 +244,11 @@ class PublishPluginCommand extends PluginCommand { // Publish the package to pub with `pub publish`. // If `_tagReleaseOption` is on, git tag the release. - // If `shouldPushTag` is `true`, the tag will be pushed to `remote`. - // Returns `true` if publishing and tag are successful. + // If `remoteForTagPush` is non-null, the tag will be pushed to that remote. + // Returns `true` if publishing and tagging are successful. Future _publishAndTagPackage({ - @required Directory packageDir, - @required String remote, - @required String remoteUrl, - @required bool shouldPushTag, + required Directory packageDir, + _RemoteInfo? remoteForTagPush, }) async { if (!await _publishPlugin(packageDir: packageDir)) { return false; @@ -251,9 +256,7 @@ class PublishPluginCommand extends PluginCommand { if (getBoolArg(_tagReleaseOption)) { if (!await _tagRelease( packageDir: packageDir, - remote: remote, - remoteUrl: remoteUrl, - shouldPushTag: shouldPushTag, + remoteForPush: remoteForTagPush, )) { return false; } @@ -264,9 +267,9 @@ class PublishPluginCommand extends PluginCommand { // Returns a [_CheckNeedsReleaseResult] that indicates the result. Future<_CheckNeedsReleaseResult> _checkNeedsRelease({ - @required File pubspecFile, - @required GitVersionFinder gitVersionFinder, - @required List existingTags, + required File pubspecFile, + required GitVersionFinder gitVersionFinder, + required List existingTags, }) async { if (!pubspecFile.existsSync()) { _print(''' @@ -287,10 +290,6 @@ Safe to ignore if the package is deleted in this commit. return _CheckNeedsReleaseResult.failure; } - if (pubspec.name == null) { - _print('Fatal: Package name is null.'); - return _CheckNeedsReleaseResult.failure; - } // Get latest tagged version and compare with the current version. // TODO(cyanglaz): Check latest version of the package on pub instead of git // https://github.com/flutter/flutter/issues/81047 @@ -301,7 +300,7 @@ Safe to ignore if the package is deleted in this commit. if (latestTag.isNotEmpty) { final String latestTaggedVersion = latestTag.split('-v').last; final Version latestVersion = Version.parse(latestTaggedVersion); - if (pubspec.version < latestVersion) { + if (pubspec.version! < latestVersion) { _print( 'The new version (${pubspec.version}) is lower than the current version ($latestVersion) for ${pubspec.name}.\nThis git commit is a revert, no release is tagged.'); return _CheckNeedsReleaseResult.noRelease; @@ -313,7 +312,7 @@ Safe to ignore if the package is deleted in this commit. // Publish the plugin. // // Returns `true` if successful, `false` otherwise. - Future _publishPlugin({@required Directory packageDir}) async { + Future _publishPlugin({required Directory packageDir}) async { final bool gitStatusOK = await _checkGitStatus(packageDir); if (!gitStatusOK) { return false; @@ -326,14 +325,13 @@ Safe to ignore if the package is deleted in this commit. return true; } - // Tag the release with -v + // Tag the release with -v, and, if [remoteForTagPush] + // is provided, push it to that remote. // // Return `true` if successful, `false` otherwise. Future _tagRelease({ - @required Directory packageDir, - @required String remote, - @required String remoteUrl, - @required bool shouldPushTag, + required Directory packageDir, + _RemoteInfo? remoteForPush, }) async { final String tag = _getTag(packageDir); _print('Tagging release $tag...'); @@ -350,23 +348,20 @@ Safe to ignore if the package is deleted in this commit. } } - if (!shouldPushTag) { + if (remoteForPush == null) { return true; } - _print('Pushing tag to $remote...'); + _print('Pushing tag to ${remoteForPush.name}...'); return await _pushTagToRemote( - remote: remote, tag: tag, - remoteUrl: remoteUrl, + remote: remoteForPush, ); } Future _finish(bool successful) async { - if (_stdinSubscription != null) { - await _stdinSubscription.cancel(); - _stdinSubscription = null; - } + await _stdinSubscription?.cancel(); + _stdinSubscription = null; if (successful) { _print('Done!'); } else { @@ -408,15 +403,15 @@ Safe to ignore if the package is deleted in this commit. return statusOutput.isEmpty; } - Future _verifyRemote(String remote) async { - final io.ProcessResult remoteInfo = await processRunner.run( + Future _verifyRemote(String remote) async { + final io.ProcessResult getRemoteUrlResult = await processRunner.run( 'git', ['remote', 'get-url', remote], workingDir: packagesDir, exitOnError: true, logOnError: true, ); - return remoteInfo.stdout as String; + return getRemoteUrlResult.stdout as String?; } Future _publish(Directory packageDir) async { @@ -471,15 +466,14 @@ Safe to ignore if the package is deleted in this commit. // // Return `true` if successful, `false` otherwise. Future _pushTagToRemote({ - @required String remote, - @required String tag, - @required String remoteUrl, + required String tag, + required _RemoteInfo remote, }) async { - assert(remote != null && tag != null && remoteUrl != null); + assert(remote != null && tag != null); if (!getBoolArg(_skipConfirmationFlag)) { - _print('Ready to push $tag to $remoteUrl (y/n)?'); - final String input = _stdin.readLineSync(); - if (input.toLowerCase() != 'y') { + _print('Ready to push $tag to ${remote.url} (y/n)?'); + final String? input = _stdin.readLineSync(); + if (input?.toLowerCase() != 'y') { _print('Tag push canceled.'); return false; } @@ -487,7 +481,7 @@ Safe to ignore if the package is deleted in this commit. if (!getBoolArg(_dryRunFlag)) { final io.ProcessResult result = await processRunner.run( 'git', - ['push', remote, tag], + ['push', remote.name, tag], workingDir: packagesDir, exitOnError: false, logOnError: true, @@ -500,15 +494,16 @@ Safe to ignore if the package is deleted in this commit. } void _ensureValidPubCredential() { - final File credentialFile = packagesDir.fileSystem.file(_credentialsPath); + final String credentialsPath = _credentialsPath; + final File credentialFile = packagesDir.fileSystem.file(credentialsPath); if (credentialFile.existsSync() && credentialFile.readAsStringSync().isNotEmpty) { return; } - final String credential = io.Platform.environment[_pubCredentialName]; + final String? credential = io.Platform.environment[_pubCredentialName]; if (credential == null) { printError(''' -No pub credential available. Please check if `~/.pub-cache/credentials.json` is valid. +No pub credential available. Please check if `$credentialsPath` is valid. If running this command on CI, you can set the pub credential content in the $_pubCredentialName environment variable. '''); throw ToolExit(1); @@ -529,17 +524,32 @@ If running this command on CI, you can set the pub credential content in the $_p final String _credentialsPath = () { // This follows the same logic as pub: // https://github.com/dart-lang/pub/blob/d99b0d58f4059d7bb4ac4616fd3d54ec00a2b5d4/lib/src/system_cache.dart#L34-L43 - String cacheDir; - final String pubCache = io.Platform.environment['PUB_CACHE']; + String? cacheDir; + final String? pubCache = io.Platform.environment['PUB_CACHE']; print(pubCache); if (pubCache != null) { cacheDir = pubCache; } else if (io.Platform.isWindows) { - final String appData = io.Platform.environment['APPDATA']; - cacheDir = p.join(appData, 'Pub', 'Cache'); + final String? appData = io.Platform.environment['APPDATA']; + if (appData == null) { + printError('"APPDATA" environment variable is not set.'); + } else { + cacheDir = p.join(appData, 'Pub', 'Cache'); + } } else { - cacheDir = p.join(io.Platform.environment['HOME'], '.pub-cache'); + final String? home = io.Platform.environment['HOME']; + if (home == null) { + printError('"HOME" environment variable is not set.'); + } else { + cacheDir = p.join(home, '.pub-cache'); + } + } + + if (cacheDir == null) { + printError('Unable to determine pub cache location'); + throw ToolExit(1); } + return p.join(cacheDir, 'credentials.json'); }(); diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 14987d47a404..1cb4245fdb73 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart=2.9 - import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; @@ -22,15 +20,15 @@ import 'util.dart'; void main() { const String testPluginName = 'foo'; - List printedMessages; - - Directory testRoot; - Directory packagesDir; - Directory pluginDir; - GitDir gitDir; - TestProcessRunner processRunner; - CommandRunner commandRunner; - MockStdin mockStdin; + late List printedMessages; + + late Directory testRoot; + late Directory packagesDir; + late Directory pluginDir; + late GitDir gitDir; + late TestProcessRunner processRunner; + late CommandRunner commandRunner; + late MockStdin mockStdin; // This test uses a local file system instead of an in memory one throughout // so that git actually works. In setup we initialize a mono repo of plugins // with one package and commit everything to Git. @@ -65,7 +63,7 @@ void main() { commandRunner = CommandRunner('tester', '') ..addCommand(PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object message) => printedMessages.add(message.toString()), + print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, gitDir: gitDir)); }); @@ -285,9 +283,9 @@ void main() { '--no-push-tags', ]); - final String tag = + final String? tag = (await gitDir.runCommand(['show-ref', 'fake_package-v0.0.1'])) - .stdout as String; + .stdout as String?; expect(tag, isNotEmpty); }); @@ -303,10 +301,10 @@ void main() { throwsA(const TypeMatcher())); expect(printedMessages, contains('Publish foo failed.')); - final String tag = (await gitDir.runCommand( + final String? tag = (await gitDir.runCommand( ['show-ref', 'fake_package-v0.0.1'], throwOnError: false)) - .stdout as String; + .stdout as String?; expect(tag, isEmpty); }); }); @@ -838,20 +836,20 @@ void main() { class TestProcessRunner extends ProcessRunner { final List results = []; // Most recent returned publish process. - MockProcess mockPublishProcess; + late MockProcess mockPublishProcess; final List mockPublishArgs = []; final MockProcessResult mockPushTagsResult = MockProcessResult(); final List pushTagsArgs = []; - String mockPublishStdout; - String mockPublishStderr; - int mockPublishCompleteCode; + String? mockPublishStdout; + String? mockPublishStderr; + int? mockPublishCompleteCode; @override Future run( String executable, List args, { - Directory workingDir, + Directory? workingDir, bool exitOnError = false, bool logOnError = false, Encoding stdoutEncoding = io.systemEncoding, @@ -874,7 +872,7 @@ class TestProcessRunner extends ProcessRunner { @override Future start(String executable, List args, - {Directory workingDirectory}) async { + {Directory? workingDirectory}) async { /// Never actually publish anything. Start is always and only used for this /// since it returns something we can route stdin through. assert(executable == 'flutter' && @@ -884,10 +882,10 @@ class TestProcessRunner extends ProcessRunner { mockPublishArgs.addAll(args); mockPublishProcess = MockProcess(); if (mockPublishStdout != null) { - mockPublishProcess.stdoutController.add(utf8.encode(mockPublishStdout)); + mockPublishProcess.stdoutController.add(utf8.encode(mockPublishStdout!)); } if (mockPublishStderr != null) { - mockPublishProcess.stderrController.add(utf8.encode(mockPublishStderr)); + mockPublishProcess.stderrController.add(utf8.encode(mockPublishStderr!)); } if (mockPublishCompleteCode != null) { mockPublishProcess.exitCodeCompleter.complete(mockPublishCompleteCode); @@ -899,8 +897,8 @@ class TestProcessRunner extends ProcessRunner { class MockStdin extends Mock implements io.Stdin { List> mockUserInputs = >[]; - StreamController> _controller; - String readLineOutput; + late StreamController> _controller; + String? readLineOutput; @override Stream transform(StreamTransformer, S> streamTransformer) { @@ -915,14 +913,14 @@ class MockStdin extends Mock implements io.Stdin { } @override - StreamSubscription> listen(void onData(List event), - {Function onError, void onDone(), bool cancelOnError}) { + StreamSubscription> listen(void onData(List event)?, + {Function? onError, void onDone()?, bool? cancelOnError}) { return _controller.stream.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); } @override - String readLineSync( + String? readLineSync( {Encoding encoding = io.systemEncoding, bool retainNewlines = false}) => readLineOutput; From f469f22c1ff5b9f6be1967d8090cf8a62c7e4001 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Mon, 14 Jun 2021 14:23:53 +0200 Subject: [PATCH 039/364] [in_app_purchase] Only register transactionObservers when someone is listening to purchaseUpdates (#4035) * Start and stop payment queue when (no longer) listened to * Added Flutter unit tests * Added native iOS unit tests * Added changelog and pubspec * Update packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m Co-authored-by: Maurits van Beusekom * Update packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart Co-authored-by: Maurits van Beusekom * Improve documentation on stopObservingTransactionQueue * formatting Co-authored-by: Maurits van Beusekom --- .../in_app_purchase_ios/CHANGELOG.md | 5 +++ .../RunnerTests/InAppPurchasePluginTests.m | 42 +++++++++++++++++++ .../example/ios/RunnerTests/Stubs.h | 1 + .../example/ios/RunnerTests/Stubs.m | 6 ++- .../ios/Classes/FIAPaymentQueueHandler.h | 2 + .../ios/Classes/FIAPaymentQueueHandler.m | 4 ++ .../ios/Classes/InAppPurchasePlugin.m | 5 ++- .../lib/src/in_app_purchase_ios_platform.dart | 10 ++++- .../sk_payment_queue_wrapper.dart | 19 +++++++++ .../in_app_purchase_ios/pubspec.yaml | 2 +- .../test/fakes/fake_ios_platform.dart | 8 ++++ .../in_app_purchase_ios_platform_test.dart | 15 +++++++ .../sk_methodchannel_apis_test.dart | 24 ++++++++++- 13 files changed, 137 insertions(+), 6 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 480426cf5e54..4b2d8ce1dc24 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.1.0+2 + +* Changed the iOS payment queue handler in such a way that it only adds a listener to the SKPaymentQueue when there + is a listener to the Dart purchaseStream. + ## 0.1.0+1 * Added a "Restore purchases" button to conform to Apple's StoreKit guidelines on [restoring products](https://developer.apple.com/documentation/storekit/in-app_purchase/restoring_purchased_products?language=objc); diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m index 6e436e414aad..241ea0d5cb0d 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m @@ -301,4 +301,46 @@ - (void)testGetPendingTransactions { XCTAssertEqualObjects(resultArray, @[ transactionMap ]); } +- (void)testStartAndStopObservingPaymentQueue { + FlutterMethodCall* startCall = [FlutterMethodCall + methodCallWithMethodName:@"-[SKPaymentQueue startObservingTransactionQueue]" + arguments:nil]; + FlutterMethodCall* stopCall = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue stopObservingTransactionQueue]" + arguments:nil]; + + SKPaymentQueueStub* queue = [SKPaymentQueueStub new]; + + self.plugin.paymentQueueHandler = + [[FIAPaymentQueueHandler alloc] initWithQueue:queue + transactionsUpdated:nil + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:^BOOL(SKPayment* _Nonnull payment, + SKProduct* _Nonnull product) { + return YES; + } + updatedDownloads:nil]; + + // Check that there is no observer to start with. + XCTAssertNil(queue.observer); + + // Start observing + [self.plugin handleMethodCall:startCall + result:^(id r){ + }]; + + // Observer should be set + XCTAssertNotNil(queue.observer); + + // Stop observing + [self.plugin handleMethodCall:stopCall + result:^(id r){ + }]; + + // No observer should be set + XCTAssertNil(queue.observer); +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h index 60c481980dff..687118febb29 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h @@ -36,6 +36,7 @@ API_AVAILABLE(ios(11.2), macos(10.13.2)) @interface SKPaymentQueueStub : SKPaymentQueue @property(assign, nonatomic) SKPaymentTransactionState testState; +@property(strong, nonatomic, nullable) id observer; @end @interface SKPaymentTransactionStub : SKPaymentTransaction diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m index 66610a88a77d..8af326a48722 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m @@ -151,8 +151,6 @@ - (SKReceiptRefreshRequestStub *)getRefreshReceiptRequest:(NSDictionary *)proper @interface SKPaymentQueueStub () -@property(strong, nonatomic) id observer; - @end @implementation SKPaymentQueueStub @@ -161,6 +159,10 @@ - (void)addTransactionObserver:(id)observer { self.observer = observer; } +- (void)removeTransactionObserver:(id)observer { + self.observer = nil; +} + - (void)addPayment:(SKPayment *)payment { SKPaymentTransactionStub *transaction = [[SKPaymentTransactionStub alloc] initWithState:self.testState payment:payment]; diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h index fddeb07e01a3..30865b2c3598 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h @@ -34,6 +34,8 @@ typedef void (^UpdatedDownloads)(NSArray *downloads); // This method needs to be called before any other methods. - (void)startObservingPaymentQueue; +// Call this method when the Flutter app is no longer listening +- (void)stopObservingPaymentQueue; // Appends a payment to the SKPaymentQueue. // diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m index eb3348e4b3c9..20ccbc5adb48 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m @@ -44,6 +44,10 @@ - (void)startObservingPaymentQueue { [_queue addTransactionObserver:self]; } +- (void)stopObservingPaymentQueue { + [_queue removeTransactionObserver:self]; +} + - (BOOL)addPayment:(SKPayment *)payment { for (SKPaymentTransaction *transaction in self.queue.transactions) { if ([transaction.payment.productIdentifier isEqualToString:payment.productIdentifier]) { diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m index 9034fe6cdd1e..8a998d9f4300 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m @@ -73,7 +73,6 @@ - (instancetype)initWithRegistrar:(NSObject *)registrar updatedDownloads:^void(NSArray *_Nonnull downloads) { [weakSelf updatedDownloads:downloads]; }]; - [_paymentQueueHandler startObservingPaymentQueue]; _callbackChannel = [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/in_app_purchase" binaryMessenger:[registrar messenger]]; @@ -100,6 +99,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [self retrieveReceiptData:call result:result]; } else if ([@"-[InAppPurchasePlugin refreshReceipt:result:]" isEqualToString:call.method]) { [self refreshReceipt:call result:result]; + } else if ([@"-[SKPaymentQueue startObservingTransactionQueue]" isEqualToString:call.method]) { + [_paymentQueueHandler startObservingPaymentQueue]; + } else if ([@"-[SKPaymentQueue stopObservingTransactionQueue]" isEqualToString:call.method]) { + [_paymentQueueHandler stopObservingPaymentQueue]; } else { result(FlutterMethodNotImplemented); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart index a83c88796343..74bb898a3382 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart @@ -51,7 +51,15 @@ class InAppPurchaseIosPlatform extends InAppPurchasePlatform { InAppPurchasePlatform.instance = InAppPurchaseIosPlatform(); _skPaymentQueueWrapper = SKPaymentQueueWrapper(); - _observer = _TransactionObserver(StreamController.broadcast()); + + // Create a purchaseUpdatedController and notify the native side when to + // start of stop sending updates. + StreamController> updateController = + StreamController.broadcast( + onListen: () => _skPaymentQueueWrapper.startObservingTransactionQueue(), + onCancel: () => _skPaymentQueueWrapper.stopObservingTransactionQueue(), + ); + _observer = _TransactionObserver(updateController); _skPaymentQueueWrapper.setTransactionObserver(observer); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart index b677772869f6..fe5f14ba44a5 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart @@ -11,6 +11,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import '../channel.dart'; +import '../in_app_purchase_ios_platform.dart'; import 'sk_payment_transaction_wrappers.dart'; import 'sk_product_wrapper.dart'; @@ -64,6 +65,24 @@ class SKPaymentQueueWrapper { channel.setMethodCallHandler(_handleObserverCallbacks); } + /// Instructs the iOS implementation to register a transaction observer and + /// start listening to it. + /// + /// Call this method when the first listener is subscribed to the + /// [InAppPurchaseIosPlatform.purchaseStream]. + Future startObservingTransactionQueue() async => + await channel.invokeListMethod( + '-[SKPaymentQueue startObservingTransactionQueue]'); + + /// Instructs the iOS implementation to remove the transaction observer and + /// stop listening to it. + /// + /// Call this when there are no longer any listeners subscribed to the + /// [InAppPurchaseIosPlatform.purchaseStream]. + Future stopObservingTransactionQueue() async => + await channel.invokeListMethod( + '-[SKPaymentQueue stopObservingTransactionQueue]'); + /// Posts a payment to the queue. /// /// This sends a purchase request to the App Store for confirmation. diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 7a3885a8a3b1..5b9e3892d40d 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.0+1 +version: 0.1.0+2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart index f39241318670..ac5c499768a1 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart @@ -29,6 +29,7 @@ class FakeIOSPlatform { PlatformException? queryProductException; PlatformException? restoreException; SKError? testRestoredError; + bool queueIsActive = false; void reset() { transactions = []; @@ -66,6 +67,7 @@ class FakeIOSPlatform { queryProductException = null; restoreException = null; testRestoredError = null; + queueIsActive = false; } SKPaymentTransactionWrapper createPendingTransaction(String id) { @@ -176,6 +178,12 @@ class FakeIOSPlatform { call.arguments["productIdentifier"], call.arguments["transactionIdentifier"])); break; + case '-[SKPaymentQueue startObservingTransactionQueue]': + queueIsActive = true; + break; + case '-[SKPaymentQueue stopObservingTransactionQueue]': + queueIsActive = false; + break; } return Future.sync(() {}); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart index b15249c81947..973b9d1da0fb 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart @@ -302,4 +302,19 @@ void main() { expect(fakeIOSPlatform.finishedTransactions.length, 1); }); }); + + group('purchase stream', () { + test('Should only have active queue when purchaseStream has listeners', () { + Stream> stream = iapIosPlatform.purchaseStream; + expect(fakeIOSPlatform.queueIsActive, false); + StreamSubscription subscription1 = stream.listen((event) {}); + expect(fakeIOSPlatform.queueIsActive, true); + StreamSubscription subscription2 = stream.listen((event) {}); + expect(fakeIOSPlatform.queueIsActive, true); + subscription1.cancel(); + expect(fakeIOSPlatform.queueIsActive, true); + subscription2.cancel(); + expect(fakeIOSPlatform.queueIsActive, false); + }); + }); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index e71279edca4f..edb50aeb62a0 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -22,6 +22,7 @@ void main() { tearDown(() { fakeIOSPlatform.testReturnNull = false; + fakeIOSPlatform.queueIsActive = null; }); group('sk_request_maker', () { @@ -132,6 +133,18 @@ void main() { await queue.restoreTransactions(applicationUserName: 'aUserID'); expect(fakeIOSPlatform.applicationNameHasTransactionRestored, 'aUserID'); }); + + test('startObservingTransactionQueue should call methodChannel', () async { + expect(fakeIOSPlatform.queueIsActive, isNot(true)); + await SKPaymentQueueWrapper().startObservingTransactionQueue(); + expect(fakeIOSPlatform.queueIsActive, true); + }); + + test('stopObservingTransactionQueue should call methodChannel', () async { + expect(fakeIOSPlatform.queueIsActive, isNot(false)); + await SKPaymentQueueWrapper().stopObservingTransactionQueue(); + expect(fakeIOSPlatform.queueIsActive, false); + }); }); group('Code Redemption Sheet', () { @@ -165,6 +178,9 @@ class FakeIOSPlatform { // present Code Redemption bool presentCodeRedemption = false; + // Listen to purchase updates + bool? queueIsActive; + Future onMethodCall(MethodCall call) { switch (call.method) { // request makers @@ -208,8 +224,14 @@ class FakeIOSPlatform { case '-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]': presentCodeRedemption = true; return Future.sync(() {}); + case '-[SKPaymentQueue startObservingTransactionQueue]': + queueIsActive = true; + return Future.sync(() {}); + case '-[SKPaymentQueue stopObservingTransactionQueue]': + queueIsActive = false; + return Future.sync(() {}); } - return Future.sync(() {}); + return Future.error('method not mocked'); } } From c473374da4ea5b7986fe30151154b58dcad1b5cc Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 15 Jun 2021 09:27:48 -0700 Subject: [PATCH 040/364] Fetch from origin before trying to switch channels (#4053) --- .cirrus.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.cirrus.yml b/.cirrus.yml index 91a656226f38..210a98b17cd3 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -15,6 +15,11 @@ tool_setup_template: &TOOL_SETUP_TEMPLATE flutter_upgrade_template: &FLUTTER_UPGRADE_TEMPLATE upgrade_flutter_script: + # Ensure that the repository has all the branches. + - cd $FLUTTER_HOME + - git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" + - git fetch origin + # Switch to the requested branch. - flutter channel $CHANNEL - flutter upgrade << : *TOOL_SETUP_TEMPLATE From 7b19e079807d7bc7bbd58e5be0df9f67ef9f84f8 Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Wed, 16 Jun 2021 11:01:40 -0700 Subject: [PATCH 041/364] Ensure Java 8 (#4058) --- .ci/java8.Dockerfile | 33 +++++++++++++++++++++++++++++++++ .cirrus.yml | 24 ++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .ci/java8.Dockerfile diff --git a/.ci/java8.Dockerfile b/.ci/java8.Dockerfile new file mode 100644 index 000000000000..fb1844a5401f --- /dev/null +++ b/.ci/java8.Dockerfile @@ -0,0 +1,33 @@ +FROM cirrusci/flutter:stable + +RUN apt-get update -y + +# Required by Roboeletric and the Android SDK. +RUN apt-get install -y openjdk-8-jdk +ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 + +RUN apt-get install -y --no-install-recommends gnupg + +# Add repo for gcloud sdk and install it +RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | \ + tee -a /etc/apt/sources.list.d/google-cloud-sdk.list + +RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | \ + apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - + +RUN apt-get update && apt-get install -y google-cloud-sdk && \ + gcloud config set core/disable_usage_reporting true && \ + gcloud config set component_manager/disable_update_check true + +RUN yes | sdkmanager \ + "platforms;android-27" \ + "build-tools;27.0.3" \ + "extras;google;m2repository" \ + "extras;android;m2repository" + +RUN yes | sdkmanager --licenses + +# Install formatter. +RUN apt-get install -y clang-format +# Required by Roboeletric and the Android SDK. +RUN apt-get install -y openjdk-8-jdk diff --git a/.cirrus.yml b/.cirrus.yml index 210a98b17cd3..dcec7bc84ca3 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -112,13 +112,17 @@ task: drive_script: - xvfb-run ./script/tool_runner.sh drive-examples --linux -# Heavy-workload Linux tasks. +# Heavy-workload Android tasks. # These use machines with more CPUs and memory, so will reduce parallelization # for non-credit runs. +# +# This has been temporarily split out from the other heavy tasks to use a +# different Docker image due to space constraints; see +# https://github.com/flutter/flutter/issues/84706 task: << : *FLUTTER_UPGRADE_TEMPLATE gke_container: - dockerfile: .ci/Dockerfile + dockerfile: .ci/java8.Dockerfile builder_image_name: docker-builder # gce vm image builder_image_project: flutter-cirrus cluster_name: test-cluster @@ -161,6 +165,22 @@ task: - fi - export CIRRUS_CHANGE_MESSAGE=`cat /tmp/cirrus_change_message.txt` - export CIRRUS_COMMIT_MESSAGE=`cat /tmp/cirrus_commit_message.txt` + +# Heavy-workload web tasks. +# These use machines with more CPUs and memory, so will reduce parallelization +# for non-credit runs. +task: + << : *FLUTTER_UPGRADE_TEMPLATE + gke_container: + dockerfile: .ci/Dockerfile + builder_image_name: docker-builder # gce vm image + builder_image_project: flutter-cirrus + cluster_name: test-cluster + zone: us-central1-a + namespace: default + cpu: 4 + memory: 12G + matrix: ### Web tasks ### - name: build-web+drive-examples env: From f73fe8f58ce90f8d941514273e73422e6db5e5f3 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 16 Jun 2021 11:53:46 -0700 Subject: [PATCH 042/364] [url_launcher] Add a workaround for Uri encoding (#3817) `Uri`'s constructor doesn't handle query parameters correctly for non-http(s) schemes, so the `mailto` example in the README is misleading. This updates the README to show using a simple method to work around that bug, and a warning about the need to use it. Fixes https://github.com/flutter/flutter/issues/75552 Fixes https://github.com/flutter/flutter/issues/73717 --- .../url_launcher/url_launcher/CHANGELOG.md | 5 +++ packages/url_launcher/url_launcher/README.md | 36 ++++++++++++------- .../url_launcher/url_launcher/pubspec.yaml | 2 +- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 03a3f6fb0b76..697b7c7816dd 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.0.7 + +* Update the README to describe a workaround to the `Uri` query + encoding bug. + ## 6.0.6 * Require `url_launcher_platform_interface` 2.0.3. This fixes an issue diff --git a/packages/url_launcher/url_launcher/README.md b/packages/url_launcher/url_launcher/README.md index 31fed9a833f1..20ee0a59caa8 100644 --- a/packages/url_launcher/url_launcher/README.md +++ b/packages/url_launcher/url_launcher/README.md @@ -10,10 +10,10 @@ To use this plugin, add `url_launcher` as a [dependency in your pubspec.yaml fil ## Installation -### iOS +### iOS Add any URL schemes passed to `canLaunch` as `LSApplicationQueriesSchemes` entries in your Info.plist file. -Example: +Example: ``` LSApplicationQueriesSchemes @@ -73,25 +73,35 @@ apps installed, so can't open `tel:` or `mailto:` links. ### Encoding URLs -URLs must be properly encoded, especially when including spaces or other special characters. This can be done using the [`Uri` class](https://api.dart.dev/stable/2.7.1/dart-core/Uri-class.html): +URLs must be properly encoded, especially when including spaces or other special +characters. This can be done using the +[`Uri` class](https://api.dart.dev/stable/2.7.1/dart-core/Uri-class.html). +For example: ```dart -import 'dart:core'; -import 'package:url_launcher/url_launcher.dart'; +String? encodeQueryParameters(Map params) { + return params.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); +} -final Uri _emailLaunchUri = Uri( +final Uri emailLaunchUri = Uri( scheme: 'mailto', path: 'smith@example.com', - queryParameters: { + query: encodeQueryParameters({ 'subject': 'Example Subject & Symbols are allowed!' - } + }), ); -// ... - -// mailto:smith@example.com?subject=Example+Subject+%26+Symbols+are+allowed%21 -launch(_emailLaunchUri.toString()); +launch(emailLaunchUri.toString()); ``` +**Warning**: For any scheme other than `http` or `https`, you should use the +`query` parameter and the `encodeQueryParameters` function shown above rather +than `Uri`'s `queryParameters` constructor argument, due to +[a bug](https://github.com/dart-lang/sdk/issues/43838) in the way `Uri` +encodes query parameters. Using `queryParameters` will result in spaces being +converted to `+` in many cases. + ## Handling missing URL receivers A particular mobile device may not be able to receive all supported URL schemes. @@ -113,4 +123,4 @@ By default, Android opens up a browser when handling URLs. You can pass If you do this for a URL of a page containing JavaScript, make sure to pass in `enableJavaScript: true`, or else the launch method will not work properly. On iOS, the default behavior is to open all web URLs within the app. Everything -else is redirected to the app handler. \ No newline at end of file +else is redirected to the app handler. diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 00cfc218ce9e..a2facbd3adf2 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.6 +version: 6.0.7 environment: sdk: ">=2.12.0 <3.0.0" From d1ddb68ebe6b7c1faafa0e32af1275f6c3f3a8b0 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 16 Jun 2021 12:37:30 -0700 Subject: [PATCH 043/364] [flutter_plugin_tools] Split common.dart (#4057) common.dart is a large-and-growing file containing all shared code, which makes it hard to navigate. To make maintenance easier, this splits the file (and its test file) into separate files for each major component or category. --- script/tool/lib/src/analyze_command.dart | 4 +- .../tool/lib/src/build_examples_command.dart | 5 +- script/tool/lib/src/common.dart | 781 ------------------ script/tool/lib/src/common/core.dart | 69 ++ .../lib/src/common/git_version_finder.dart | 81 ++ .../tool/lib/src/common/plugin_command.dart | 353 ++++++++ script/tool/lib/src/common/plugin_utils.dart | 110 +++ .../tool/lib/src/common/process_runner.dart | 104 +++ .../lib/src/common/pub_version_finder.dart | 103 +++ .../src/create_all_plugins_app_command.dart | 3 +- .../tool/lib/src/drive_examples_command.dart | 7 +- .../lib/src/firebase_test_lab_command.dart | 4 +- script/tool/lib/src/format_command.dart | 5 +- script/tool/lib/src/java_test_command.dart | 6 +- .../tool/lib/src/license_check_command.dart | 5 +- .../tool/lib/src/lint_podspecs_command.dart | 4 +- script/tool/lib/src/list_command.dart | 4 +- script/tool/lib/src/main.dart | 2 +- .../tool/lib/src/publish_check_command.dart | 5 +- .../tool/lib/src/publish_plugin_command.dart | 5 +- .../tool/lib/src/pubspec_check_command.dart | 6 +- script/tool/lib/src/test_command.dart | 7 +- .../tool/lib/src/version_check_command.dart | 8 +- script/tool/lib/src/xctest_command.dart | 6 +- script/tool/test/analyze_command_test.dart | 2 +- .../test/common/git_version_finder_test.dart | 93 +++ .../plugin_command_test.dart} | 361 +------- .../plugin_command_test.mocks.dart} | 0 .../tool/test/common/plugin_utils_test.dart | 210 +++++ .../test/common/pub_version_finder_test.dart | 89 ++ .../test/drive_examples_command_test.dart | 2 +- script/tool/test/firebase_test_lab_test.dart | 2 +- .../tool/test/license_check_command_test.dart | 2 +- .../tool/test/publish_check_command_test.dart | 2 +- .../test/publish_plugin_command_test.dart | 3 +- .../tool/test/pubspec_check_command_test.dart | 2 +- script/tool/test/util.dart | 3 +- script/tool/test/version_check_test.dart | 4 +- script/tool/test/xctest_command_test.dart | 3 +- 39 files changed, 1284 insertions(+), 1181 deletions(-) delete mode 100644 script/tool/lib/src/common.dart create mode 100644 script/tool/lib/src/common/core.dart create mode 100644 script/tool/lib/src/common/git_version_finder.dart create mode 100644 script/tool/lib/src/common/plugin_command.dart create mode 100644 script/tool/lib/src/common/plugin_utils.dart create mode 100644 script/tool/lib/src/common/process_runner.dart create mode 100644 script/tool/lib/src/common/pub_version_finder.dart create mode 100644 script/tool/test/common/git_version_finder_test.dart rename script/tool/test/{common_test.dart => common/plugin_command_test.dart} (51%) rename script/tool/test/{common_test.mocks.dart => common/plugin_command_test.mocks.dart} (100%) create mode 100644 script/tool/test/common/plugin_utils_test.dart create mode 100644 script/tool/test/common/pub_version_finder_test.dart diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 076c8f69885d..003f0bcda82d 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -7,7 +7,9 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:path/path.dart' as p; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; /// A command to run Dart analysis on packages. class AnalyzeCommand extends PluginCommand { diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 9590aecef98e..61d291d87c68 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -9,7 +9,10 @@ import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/plugin_utils.dart'; +import 'common/process_runner.dart'; /// Key for IPA. const String kIpa = 'ipa'; diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart deleted file mode 100644 index 5d653ad0ed20..000000000000 --- a/script/tool/lib/src/common.dart +++ /dev/null @@ -1,781 +0,0 @@ -// Copyright 2013 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:async'; -import 'dart:convert'; -import 'dart:io' as io; -import 'dart:math'; - -import 'package:args/command_runner.dart'; -import 'package:colorize/colorize.dart'; -import 'package:file/file.dart'; -import 'package:git/git.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:pub_semver/pub_semver.dart'; -import 'package:yaml/yaml.dart'; - -/// The signature for a print handler for commands that allow overriding the -/// print destination. -typedef Print = void Function(Object? object); - -/// Key for windows platform. -const String kPlatformFlagWindows = 'windows'; - -/// Key for macos platform. -const String kPlatformFlagMacos = 'macos'; - -/// Key for linux platform. -const String kPlatformFlagLinux = 'linux'; - -/// Key for IPA (iOS) platform. -const String kPlatformFlagIos = 'ios'; - -/// Key for APK (Android) platform. -const String kPlatformFlagAndroid = 'android'; - -/// Key for Web platform. -const String kPlatformFlagWeb = 'web'; - -/// Key for enable experiment. -const String kEnableExperiment = 'enable-experiment'; - -/// Returns whether the given directory contains a Flutter package. -bool isFlutterPackage(FileSystemEntity entity) { - if (entity is! Directory) { - return false; - } - - try { - final File pubspecFile = entity.childFile('pubspec.yaml'); - final YamlMap pubspecYaml = - loadYaml(pubspecFile.readAsStringSync()) as YamlMap; - final YamlMap? dependencies = pubspecYaml['dependencies'] as YamlMap?; - if (dependencies == null) { - return false; - } - return dependencies.containsKey('flutter'); - } on FileSystemException { - return false; - } on YamlException { - return false; - } -} - -/// Possible plugin support options for a platform. -enum PlatformSupport { - /// The platform has an implementation in the package. - inline, - - /// The platform has an endorsed federated implementation in another package. - federated, -} - -/// Returns whether the given directory contains a Flutter [platform] plugin. -/// -/// It checks this by looking for the following pattern in the pubspec: -/// -/// flutter: -/// plugin: -/// platforms: -/// [platform]: -/// -/// If [requiredMode] is provided, the plugin must have the given type of -/// implementation in order to return true. -bool pluginSupportsPlatform(String platform, FileSystemEntity entity, - {PlatformSupport? requiredMode}) { - assert(platform == kPlatformFlagIos || - platform == kPlatformFlagAndroid || - platform == kPlatformFlagWeb || - platform == kPlatformFlagMacos || - platform == kPlatformFlagWindows || - platform == kPlatformFlagLinux); - if (entity is! Directory) { - return false; - } - - try { - final File pubspecFile = entity.childFile('pubspec.yaml'); - final YamlMap pubspecYaml = - loadYaml(pubspecFile.readAsStringSync()) as YamlMap; - final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; - if (flutterSection == null) { - return false; - } - final YamlMap? pluginSection = flutterSection['plugin'] as YamlMap?; - if (pluginSection == null) { - return false; - } - final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; - if (platforms == null) { - // Legacy plugin specs are assumed to support iOS and Android. They are - // never federated. - if (requiredMode == PlatformSupport.federated) { - return false; - } - if (!pluginSection.containsKey('platforms')) { - return platform == kPlatformFlagIos || platform == kPlatformFlagAndroid; - } - return false; - } - final YamlMap? platformEntry = platforms[platform] as YamlMap?; - if (platformEntry == null) { - return false; - } - // If the platform entry is present, then it supports the platform. Check - // for required mode if specified. - final bool federated = platformEntry.containsKey('default_package'); - return requiredMode == null || - federated == (requiredMode == PlatformSupport.federated); - } on FileSystemException { - return false; - } on YamlException { - return false; - } -} - -/// Returns whether the given directory contains a Flutter Android plugin. -bool isAndroidPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagAndroid, entity); -} - -/// Returns whether the given directory contains a Flutter iOS plugin. -bool isIosPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagIos, entity); -} - -/// Returns whether the given directory contains a Flutter web plugin. -bool isWebPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagWeb, entity); -} - -/// Returns whether the given directory contains a Flutter Windows plugin. -bool isWindowsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagWindows, entity); -} - -/// Returns whether the given directory contains a Flutter macOS plugin. -bool isMacOsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagMacos, entity); -} - -/// Returns whether the given directory contains a Flutter linux plugin. -bool isLinuxPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagLinux, entity); -} - -/// Prints `errorMessage` in red. -void printError(String errorMessage) { - final Colorize redError = Colorize(errorMessage)..red(); - print(redError); -} - -/// Error thrown when a command needs to exit with a non-zero exit code. -class ToolExit extends Error { - /// Creates a tool exit with the given [exitCode]. - ToolExit(this.exitCode); - - /// The code that the process should exit with. - final int exitCode; -} - -/// Interface definition for all commands in this tool. -abstract class PluginCommand extends Command { - /// Creates a command to operate on [packagesDir] with the given environment. - PluginCommand( - this.packagesDir, { - this.processRunner = const ProcessRunner(), - this.gitDir, - }) { - argParser.addMultiOption( - _pluginsArg, - splitCommas: true, - help: - 'Specifies which plugins the command should run on (before sharding).', - valueHelp: 'plugin1,plugin2,...', - ); - argParser.addOption( - _shardIndexArg, - help: 'Specifies the zero-based index of the shard to ' - 'which the command applies.', - valueHelp: 'i', - defaultsTo: '0', - ); - argParser.addOption( - _shardCountArg, - help: 'Specifies the number of shards into which plugins are divided.', - valueHelp: 'n', - defaultsTo: '1', - ); - argParser.addMultiOption( - _excludeArg, - abbr: 'e', - help: 'Exclude packages from this command.', - defaultsTo: [], - ); - argParser.addFlag(_runOnChangedPackagesArg, - help: 'Run the command on changed packages/plugins.\n' - 'If the $_pluginsArg is specified, this flag is ignored.\n' - 'If no packages have changed, or if there have been changes that may\n' - 'affect all packages, the command runs on all packages.\n' - 'The packages excluded with $_excludeArg is also excluded even if changed.\n' - 'See $_kBaseSha if a custom base is needed to determine the diff.'); - argParser.addOption(_kBaseSha, - help: 'The base sha used to determine git diff. \n' - 'This is useful when $_runOnChangedPackagesArg is specified.\n' - 'If not specified, merge-base is used as base sha.'); - } - - static const String _pluginsArg = 'plugins'; - static const String _shardIndexArg = 'shardIndex'; - static const String _shardCountArg = 'shardCount'; - static const String _excludeArg = 'exclude'; - static const String _runOnChangedPackagesArg = 'run-on-changed-packages'; - static const String _kBaseSha = 'base-sha'; - - /// The directory containing the plugin packages. - final Directory packagesDir; - - /// The process runner. - /// - /// This can be overridden for testing. - final ProcessRunner processRunner; - - /// The git directory to use. By default it uses the parent directory. - /// - /// This can be mocked for testing. - final GitDir? gitDir; - - int? _shardIndex; - int? _shardCount; - - /// The shard of the overall command execution that this instance should run. - int get shardIndex { - if (_shardIndex == null) { - _checkSharding(); - } - return _shardIndex!; - } - - /// The number of shards this command is divided into. - int get shardCount { - if (_shardCount == null) { - _checkSharding(); - } - return _shardCount!; - } - - /// Convenience accessor for boolean arguments. - bool getBoolArg(String key) { - return (argResults![key] as bool?) ?? false; - } - - /// Convenience accessor for String arguments. - String getStringArg(String key) { - return (argResults![key] as String?) ?? ''; - } - - /// Convenience accessor for List arguments. - List getStringListArg(String key) { - return (argResults![key] as List?) ?? []; - } - - void _checkSharding() { - final int? shardIndex = int.tryParse(getStringArg(_shardIndexArg)); - final int? shardCount = int.tryParse(getStringArg(_shardCountArg)); - if (shardIndex == null) { - usageException('$_shardIndexArg must be an integer'); - } - if (shardCount == null) { - usageException('$_shardCountArg must be an integer'); - } - if (shardCount < 1) { - usageException('$_shardCountArg must be positive'); - } - if (shardIndex < 0 || shardCount <= shardIndex) { - usageException( - '$_shardIndexArg must be in the half-open range [0..$shardCount['); - } - _shardIndex = shardIndex; - _shardCount = shardCount; - } - - /// Returns the root Dart package folders of the plugins involved in this - /// command execution. - Stream getPlugins() async* { - // To avoid assuming consistency of `Directory.list` across command - // invocations, we collect and sort the plugin folders before sharding. - // This is considered an implementation detail which is why the API still - // uses streams. - final List allPlugins = await _getAllPlugins().toList(); - allPlugins.sort((Directory d1, Directory d2) => d1.path.compareTo(d2.path)); - // Sharding 10 elements into 3 shards should yield shard sizes 4, 4, 2. - // Sharding 9 elements into 3 shards should yield shard sizes 3, 3, 3. - // Sharding 2 elements into 3 shards should yield shard sizes 1, 1, 0. - final int shardSize = allPlugins.length ~/ shardCount + - (allPlugins.length % shardCount == 0 ? 0 : 1); - final int start = min(shardIndex * shardSize, allPlugins.length); - final int end = min(start + shardSize, allPlugins.length); - - for (final Directory plugin in allPlugins.sublist(start, end)) { - yield plugin; - } - } - - /// Returns the root Dart package folders of the plugins involved in this - /// command execution, assuming there is only one shard. - /// - /// Plugin packages can exist in the following places relative to the packages - /// directory: - /// - /// 1. As a Dart package in a directory which is a direct child of the - /// packages directory. This is a plugin where all of the implementations - /// exist in a single Dart package. - /// 2. Several plugin packages may live in a directory which is a direct - /// child of the packages directory. This directory groups several Dart - /// packages which implement a single plugin. This directory contains a - /// "client library" package, which declares the API for the plugin, as - /// well as one or more platform-specific implementations. - /// 3./4. Either of the above, but in a third_party/packages/ directory that - /// is a sibling of the packages directory. This is used for a small number - /// of packages in the flutter/packages repository. - Stream _getAllPlugins() async* { - Set plugins = Set.from(getStringListArg(_pluginsArg)); - final Set excludedPlugins = - Set.from(getStringListArg(_excludeArg)); - final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); - if (plugins.isEmpty && - runOnChangedPackages && - !(await _changesRequireFullTest())) { - plugins = await _getChangedPackages(); - } - - final Directory thirdPartyPackagesDirectory = packagesDir.parent - .childDirectory('third_party') - .childDirectory('packages'); - - for (final Directory dir in [ - packagesDir, - if (thirdPartyPackagesDirectory.existsSync()) thirdPartyPackagesDirectory, - ]) { - await for (final FileSystemEntity entity - in dir.list(followLinks: false)) { - // A top-level Dart package is a plugin package. - if (_isDartPackage(entity)) { - if (!excludedPlugins.contains(entity.basename) && - (plugins.isEmpty || plugins.contains(p.basename(entity.path)))) { - yield entity as Directory; - } - } else if (entity is Directory) { - // Look for Dart packages under this top-level directory. - await for (final FileSystemEntity subdir - in entity.list(followLinks: false)) { - if (_isDartPackage(subdir)) { - // If --plugin=my_plugin is passed, then match all federated - // plugins under 'my_plugin'. Also match if the exact plugin is - // passed. - final String relativePath = - p.relative(subdir.path, from: dir.path); - final String packageName = p.basename(subdir.path); - final String basenamePath = p.basename(entity.path); - if (!excludedPlugins.contains(basenamePath) && - !excludedPlugins.contains(packageName) && - !excludedPlugins.contains(relativePath) && - (plugins.isEmpty || - plugins.contains(relativePath) || - plugins.contains(basenamePath))) { - yield subdir as Directory; - } - } - } - } - } - } - } - - /// Returns the example Dart package folders of the plugins involved in this - /// command execution. - Stream getExamples() => - getPlugins().expand(getExamplesForPlugin); - - /// Returns all Dart package folders (typically, plugin + example) of the - /// plugins involved in this command execution. - Stream getPackages() async* { - await for (final Directory plugin in getPlugins()) { - yield plugin; - yield* plugin - .list(recursive: true, followLinks: false) - .where(_isDartPackage) - .cast(); - } - } - - /// Returns the files contained, recursively, within the plugins - /// involved in this command execution. - Stream getFiles() { - return getPlugins().asyncExpand((Directory folder) => folder - .list(recursive: true, followLinks: false) - .where((FileSystemEntity entity) => entity is File) - .cast()); - } - - /// Returns whether the specified entity is a directory containing a - /// `pubspec.yaml` file. - bool _isDartPackage(FileSystemEntity entity) { - return entity is Directory && entity.childFile('pubspec.yaml').existsSync(); - } - - /// Returns the example Dart packages contained in the specified plugin, or - /// an empty List, if the plugin has no examples. - Iterable getExamplesForPlugin(Directory plugin) { - final Directory exampleFolder = plugin.childDirectory('example'); - if (!exampleFolder.existsSync()) { - return []; - } - if (isFlutterPackage(exampleFolder)) { - return [exampleFolder]; - } - // Only look at the subdirectories of the example directory if the example - // directory itself is not a Dart package, and only look one level below the - // example directory for other dart packages. - return exampleFolder - .listSync() - .where((FileSystemEntity entity) => isFlutterPackage(entity)) - .cast(); - } - - /// Retrieve an instance of [GitVersionFinder] based on `_kBaseSha` and [gitDir]. - /// - /// Throws tool exit if [gitDir] nor root directory is a git directory. - Future retrieveVersionFinder() async { - final String rootDir = packagesDir.parent.absolute.path; - final String baseSha = getStringArg(_kBaseSha); - - GitDir? baseGitDir = gitDir; - if (baseGitDir == null) { - if (!await GitDir.isGitDir(rootDir)) { - printError( - '$rootDir is not a valid Git repository.', - ); - throw ToolExit(2); - } - baseGitDir = await GitDir.fromExisting(rootDir); - } - - final GitVersionFinder gitVersionFinder = - GitVersionFinder(baseGitDir, baseSha); - return gitVersionFinder; - } - - // Returns packages that have been changed relative to the git base. - Future> _getChangedPackages() async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); - - final List allChangedFiles = - await gitVersionFinder.getChangedFiles(); - final Set packages = {}; - for (final String path in allChangedFiles) { - final List pathComponents = path.split('/'); - final int packagesIndex = - pathComponents.indexWhere((String element) => element == 'packages'); - if (packagesIndex != -1) { - packages.add(pathComponents[packagesIndex + 1]); - } - } - if (packages.isEmpty) { - print('No changed packages.'); - } else { - final String changedPackages = packages.join(','); - print('Changed packages: $changedPackages'); - } - return packages; - } - - // Returns true if one or more files changed that have the potential to affect - // any plugin (e.g., CI script changes). - Future _changesRequireFullTest() async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); - - const List specialFiles = [ - '.ci.yaml', // LUCI config. - '.cirrus.yml', // Cirrus config. - '.clang-format', // ObjC and C/C++ formatting options. - 'analysis_options.yaml', // Dart analysis settings. - ]; - const List specialDirectories = [ - '.ci/', // Support files for CI. - 'script/', // This tool, and its wrapper scripts. - ]; - // Directory entries must end with / to avoid over-matching, since the - // check below is done via string prefixing. - assert(specialDirectories.every((String dir) => dir.endsWith('/'))); - - final List allChangedFiles = - await gitVersionFinder.getChangedFiles(); - return allChangedFiles.any((String path) => - specialFiles.contains(path) || - specialDirectories.any((String dir) => path.startsWith(dir))); - } -} - -/// A class used to run processes. -/// -/// We use this instead of directly running the process so it can be overridden -/// in tests. -class ProcessRunner { - /// Creates a new process runner. - const ProcessRunner(); - - /// Run the [executable] with [args] and stream output to stderr and stdout. - /// - /// The current working directory of [executable] can be overridden by - /// passing [workingDir]. - /// - /// If [exitOnError] is set to `true`, then this will throw an error if - /// the [executable] terminates with a non-zero exit code. - /// - /// Returns the exit code of the [executable]. - Future runAndStream( - String executable, - List args, { - Directory? workingDir, - bool exitOnError = false, - }) async { - print( - 'Running command: "$executable ${args.join(' ')}" in ${workingDir?.path ?? io.Directory.current.path}'); - final io.Process process = await io.Process.start(executable, args, - workingDirectory: workingDir?.path); - await io.stdout.addStream(process.stdout); - await io.stderr.addStream(process.stderr); - if (exitOnError && await process.exitCode != 0) { - final String error = - _getErrorString(executable, args, workingDir: workingDir); - print('$error See above for details.'); - throw ToolExit(await process.exitCode); - } - return process.exitCode; - } - - /// Run the [executable] with [args]. - /// - /// The current working directory of [executable] can be overridden by - /// passing [workingDir]. - /// - /// If [exitOnError] is set to `true`, then this will throw an error if - /// the [executable] terminates with a non-zero exit code. - /// Defaults to `false`. - /// - /// If [logOnError] is set to `true`, it will print a formatted message about the error. - /// Defaults to `false` - /// - /// Returns the [io.ProcessResult] of the [executable]. - Future run(String executable, List args, - {Directory? workingDir, - bool exitOnError = false, - bool logOnError = false, - Encoding stdoutEncoding = io.systemEncoding, - Encoding stderrEncoding = io.systemEncoding}) async { - final io.ProcessResult result = await io.Process.run(executable, args, - workingDirectory: workingDir?.path, - stdoutEncoding: stdoutEncoding, - stderrEncoding: stderrEncoding); - if (result.exitCode != 0) { - if (logOnError) { - final String error = - _getErrorString(executable, args, workingDir: workingDir); - print('$error Stderr:\n${result.stdout}'); - } - if (exitOnError) { - throw ToolExit(result.exitCode); - } - } - return result; - } - - /// Starts the [executable] with [args]. - /// - /// The current working directory of [executable] can be overridden by - /// passing [workingDir]. - /// - /// Returns the started [io.Process]. - Future start(String executable, List args, - {Directory? workingDirectory}) async { - final io.Process process = await io.Process.start(executable, args, - workingDirectory: workingDirectory?.path); - return process; - } - - String _getErrorString(String executable, List args, - {Directory? workingDir}) { - final String workdir = workingDir == null ? '' : ' in ${workingDir.path}'; - return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.'; - } -} - -/// Finding version of [package] that is published on pub. -class PubVersionFinder { - /// Constructor. - /// - /// Note: you should manually close the [httpClient] when done using the finder. - PubVersionFinder({this.pubHost = defaultPubHost, required this.httpClient}); - - /// The default pub host to use. - static const String defaultPubHost = 'https://pub.dev'; - - /// The pub host url, defaults to `https://pub.dev`. - final String pubHost; - - /// The http client. - /// - /// You should manually close this client when done using this finder. - final http.Client httpClient; - - /// Get the package version on pub. - Future getPackageVersion( - {required String package}) async { - assert(package.isNotEmpty); - final Uri pubHostUri = Uri.parse(pubHost); - final Uri url = pubHostUri.replace(path: '/packages/$package.json'); - final http.Response response = await httpClient.get(url); - - if (response.statusCode == 404) { - return PubVersionFinderResponse( - versions: [], - result: PubVersionFinderResult.noPackageFound, - httpResponse: response); - } else if (response.statusCode != 200) { - return PubVersionFinderResponse( - versions: [], - result: PubVersionFinderResult.fail, - httpResponse: response); - } - final List versions = - (json.decode(response.body)['versions'] as List) - .map((final dynamic versionString) => - Version.parse(versionString as String)) - .toList(); - - return PubVersionFinderResponse( - versions: versions, - result: PubVersionFinderResult.success, - httpResponse: response); - } -} - -/// Represents a response for [PubVersionFinder]. -class PubVersionFinderResponse { - /// Constructor. - PubVersionFinderResponse( - {required this.versions, - required this.result, - required this.httpResponse}) { - if (versions.isNotEmpty) { - versions.sort((Version a, Version b) { - // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize]. - // https://github.com/flutter/flutter/issues/82222 - return b.compareTo(a); - }); - } - } - - /// The versions found in [PubVersionFinder]. - /// - /// This is sorted by largest to smallest, so the first element in the list is the largest version. - /// Might be `null` if the [result] is not [PubVersionFinderResult.success]. - final List versions; - - /// The result of the version finder. - final PubVersionFinderResult result; - - /// The response object of the http request. - final http.Response httpResponse; -} - -/// An enum representing the result of [PubVersionFinder]. -enum PubVersionFinderResult { - /// The version finder successfully found a version. - success, - - /// The version finder failed to find a valid version. - /// - /// This might due to http connection errors or user errors. - fail, - - /// The version finder failed to locate the package. - /// - /// This indicates the package is new. - noPackageFound, -} - -/// Finding diffs based on `baseGitDir` and `baseSha`. -class GitVersionFinder { - /// Constructor - GitVersionFinder(this.baseGitDir, this.baseSha); - - /// The top level directory of the git repo. - /// - /// That is where the .git/ folder exists. - final GitDir baseGitDir; - - /// The base sha used to get diff. - final String? baseSha; - - static bool _isPubspec(String file) { - return file.trim().endsWith('pubspec.yaml'); - } - - /// Get a list of all the pubspec.yaml file that is changed. - Future> getChangedPubSpecs() async { - return (await getChangedFiles()).where(_isPubspec).toList(); - } - - /// Get a list of all the changed files. - Future> getChangedFiles() async { - final String baseSha = await _getBaseSha(); - final io.ProcessResult changedFilesCommand = await baseGitDir - .runCommand(['diff', '--name-only', baseSha, 'HEAD']); - print('Determine diff with base sha: $baseSha'); - final String changedFilesStdout = changedFilesCommand.stdout.toString(); - if (changedFilesStdout.isEmpty) { - return []; - } - final List changedFiles = changedFilesStdout.split('\n') - ..removeWhere((String element) => element.isEmpty); - return changedFiles.toList(); - } - - /// Get the package version specified in the pubspec file in `pubspecPath` and - /// at the revision of `gitRef` (defaulting to the base if not provided). - Future getPackageVersion(String pubspecPath, - {String? gitRef}) async { - final String ref = gitRef ?? (await _getBaseSha()); - - io.ProcessResult gitShow; - try { - gitShow = - await baseGitDir.runCommand(['show', '$ref:$pubspecPath']); - } on io.ProcessException { - return null; - } - final String fileContent = gitShow.stdout as String; - final String? versionString = loadYaml(fileContent)['version'] as String?; - return versionString == null ? null : Version.parse(versionString); - } - - Future _getBaseSha() async { - if (baseSha != null && baseSha!.isNotEmpty) { - return baseSha!; - } - - io.ProcessResult baseShaFromMergeBase = await baseGitDir.runCommand( - ['merge-base', '--fork-point', 'FETCH_HEAD', 'HEAD'], - throwOnError: false); - if (baseShaFromMergeBase.stderr != null || - baseShaFromMergeBase.stdout == null) { - baseShaFromMergeBase = await baseGitDir - .runCommand(['merge-base', 'FETCH_HEAD', 'HEAD']); - } - return (baseShaFromMergeBase.stdout as String).trim(); - } -} diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart new file mode 100644 index 000000000000..4788b9fa9143 --- /dev/null +++ b/script/tool/lib/src/common/core.dart @@ -0,0 +1,69 @@ +// Copyright 2013 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 'package:colorize/colorize.dart'; +import 'package:file/file.dart'; +import 'package:yaml/yaml.dart'; + +/// The signature for a print handler for commands that allow overriding the +/// print destination. +typedef Print = void Function(Object? object); + +/// Key for windows platform. +const String kPlatformFlagWindows = 'windows'; + +/// Key for macos platform. +const String kPlatformFlagMacos = 'macos'; + +/// Key for linux platform. +const String kPlatformFlagLinux = 'linux'; + +/// Key for IPA (iOS) platform. +const String kPlatformFlagIos = 'ios'; + +/// Key for APK (Android) platform. +const String kPlatformFlagAndroid = 'android'; + +/// Key for Web platform. +const String kPlatformFlagWeb = 'web'; + +/// Key for enable experiment. +const String kEnableExperiment = 'enable-experiment'; + +/// Returns whether the given directory contains a Flutter package. +bool isFlutterPackage(FileSystemEntity entity) { + if (entity is! Directory) { + return false; + } + + try { + final File pubspecFile = entity.childFile('pubspec.yaml'); + final YamlMap pubspecYaml = + loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + final YamlMap? dependencies = pubspecYaml['dependencies'] as YamlMap?; + if (dependencies == null) { + return false; + } + return dependencies.containsKey('flutter'); + } on FileSystemException { + return false; + } on YamlException { + return false; + } +} + +/// Prints `errorMessage` in red. +void printError(String errorMessage) { + final Colorize redError = Colorize(errorMessage)..red(); + print(redError); +} + +/// Error thrown when a command needs to exit with a non-zero exit code. +class ToolExit extends Error { + /// Creates a tool exit with the given [exitCode]. + ToolExit(this.exitCode); + + /// The code that the process should exit with. + final int exitCode; +} diff --git a/script/tool/lib/src/common/git_version_finder.dart b/script/tool/lib/src/common/git_version_finder.dart new file mode 100644 index 000000000000..2c9519e7a856 --- /dev/null +++ b/script/tool/lib/src/common/git_version_finder.dart @@ -0,0 +1,81 @@ +// Copyright 2013 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:io' as io; + +import 'package:git/git.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; + +/// Finding diffs based on `baseGitDir` and `baseSha`. +class GitVersionFinder { + /// Constructor + GitVersionFinder(this.baseGitDir, this.baseSha); + + /// The top level directory of the git repo. + /// + /// That is where the .git/ folder exists. + final GitDir baseGitDir; + + /// The base sha used to get diff. + final String? baseSha; + + static bool _isPubspec(String file) { + return file.trim().endsWith('pubspec.yaml'); + } + + /// Get a list of all the pubspec.yaml file that is changed. + Future> getChangedPubSpecs() async { + return (await getChangedFiles()).where(_isPubspec).toList(); + } + + /// Get a list of all the changed files. + Future> getChangedFiles() async { + final String baseSha = await _getBaseSha(); + final io.ProcessResult changedFilesCommand = await baseGitDir + .runCommand(['diff', '--name-only', baseSha, 'HEAD']); + print('Determine diff with base sha: $baseSha'); + final String changedFilesStdout = changedFilesCommand.stdout.toString(); + if (changedFilesStdout.isEmpty) { + return []; + } + final List changedFiles = changedFilesStdout.split('\n') + ..removeWhere((String element) => element.isEmpty); + return changedFiles.toList(); + } + + /// Get the package version specified in the pubspec file in `pubspecPath` and + /// at the revision of `gitRef` (defaulting to the base if not provided). + Future getPackageVersion(String pubspecPath, + {String? gitRef}) async { + final String ref = gitRef ?? (await _getBaseSha()); + + io.ProcessResult gitShow; + try { + gitShow = + await baseGitDir.runCommand(['show', '$ref:$pubspecPath']); + } on io.ProcessException { + return null; + } + final String fileContent = gitShow.stdout as String; + final String? versionString = loadYaml(fileContent)['version'] as String?; + return versionString == null ? null : Version.parse(versionString); + } + + Future _getBaseSha() async { + if (baseSha != null && baseSha!.isNotEmpty) { + return baseSha!; + } + + io.ProcessResult baseShaFromMergeBase = await baseGitDir.runCommand( + ['merge-base', '--fork-point', 'FETCH_HEAD', 'HEAD'], + throwOnError: false); + if (baseShaFromMergeBase.stderr != null || + baseShaFromMergeBase.stdout == null) { + baseShaFromMergeBase = await baseGitDir + .runCommand(['merge-base', 'FETCH_HEAD', 'HEAD']); + } + return (baseShaFromMergeBase.stdout as String).trim(); + } +} diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart new file mode 100644 index 000000000000..1ab9d8dcc6e0 --- /dev/null +++ b/script/tool/lib/src/common/plugin_command.dart @@ -0,0 +1,353 @@ +// Copyright 2013 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:math'; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; + +import 'core.dart'; +import 'git_version_finder.dart'; +import 'process_runner.dart'; + +/// Interface definition for all commands in this tool. +abstract class PluginCommand extends Command { + /// Creates a command to operate on [packagesDir] with the given environment. + PluginCommand( + this.packagesDir, { + this.processRunner = const ProcessRunner(), + this.gitDir, + }) { + argParser.addMultiOption( + _pluginsArg, + splitCommas: true, + help: + 'Specifies which plugins the command should run on (before sharding).', + valueHelp: 'plugin1,plugin2,...', + ); + argParser.addOption( + _shardIndexArg, + help: 'Specifies the zero-based index of the shard to ' + 'which the command applies.', + valueHelp: 'i', + defaultsTo: '0', + ); + argParser.addOption( + _shardCountArg, + help: 'Specifies the number of shards into which plugins are divided.', + valueHelp: 'n', + defaultsTo: '1', + ); + argParser.addMultiOption( + _excludeArg, + abbr: 'e', + help: 'Exclude packages from this command.', + defaultsTo: [], + ); + argParser.addFlag(_runOnChangedPackagesArg, + help: 'Run the command on changed packages/plugins.\n' + 'If the $_pluginsArg is specified, this flag is ignored.\n' + 'If no packages have changed, or if there have been changes that may\n' + 'affect all packages, the command runs on all packages.\n' + 'The packages excluded with $_excludeArg is also excluded even if changed.\n' + 'See $_kBaseSha if a custom base is needed to determine the diff.'); + argParser.addOption(_kBaseSha, + help: 'The base sha used to determine git diff. \n' + 'This is useful when $_runOnChangedPackagesArg is specified.\n' + 'If not specified, merge-base is used as base sha.'); + } + + static const String _pluginsArg = 'plugins'; + static const String _shardIndexArg = 'shardIndex'; + static const String _shardCountArg = 'shardCount'; + static const String _excludeArg = 'exclude'; + static const String _runOnChangedPackagesArg = 'run-on-changed-packages'; + static const String _kBaseSha = 'base-sha'; + + /// The directory containing the plugin packages. + final Directory packagesDir; + + /// The process runner. + /// + /// This can be overridden for testing. + final ProcessRunner processRunner; + + /// The git directory to use. By default it uses the parent directory. + /// + /// This can be mocked for testing. + final GitDir? gitDir; + + int? _shardIndex; + int? _shardCount; + + /// The shard of the overall command execution that this instance should run. + int get shardIndex { + if (_shardIndex == null) { + _checkSharding(); + } + return _shardIndex!; + } + + /// The number of shards this command is divided into. + int get shardCount { + if (_shardCount == null) { + _checkSharding(); + } + return _shardCount!; + } + + /// Convenience accessor for boolean arguments. + bool getBoolArg(String key) { + return (argResults![key] as bool?) ?? false; + } + + /// Convenience accessor for String arguments. + String getStringArg(String key) { + return (argResults![key] as String?) ?? ''; + } + + /// Convenience accessor for List arguments. + List getStringListArg(String key) { + return (argResults![key] as List?) ?? []; + } + + void _checkSharding() { + final int? shardIndex = int.tryParse(getStringArg(_shardIndexArg)); + final int? shardCount = int.tryParse(getStringArg(_shardCountArg)); + if (shardIndex == null) { + usageException('$_shardIndexArg must be an integer'); + } + if (shardCount == null) { + usageException('$_shardCountArg must be an integer'); + } + if (shardCount < 1) { + usageException('$_shardCountArg must be positive'); + } + if (shardIndex < 0 || shardCount <= shardIndex) { + usageException( + '$_shardIndexArg must be in the half-open range [0..$shardCount['); + } + _shardIndex = shardIndex; + _shardCount = shardCount; + } + + /// Returns the root Dart package folders of the plugins involved in this + /// command execution. + Stream getPlugins() async* { + // To avoid assuming consistency of `Directory.list` across command + // invocations, we collect and sort the plugin folders before sharding. + // This is considered an implementation detail which is why the API still + // uses streams. + final List allPlugins = await _getAllPlugins().toList(); + allPlugins.sort((Directory d1, Directory d2) => d1.path.compareTo(d2.path)); + // Sharding 10 elements into 3 shards should yield shard sizes 4, 4, 2. + // Sharding 9 elements into 3 shards should yield shard sizes 3, 3, 3. + // Sharding 2 elements into 3 shards should yield shard sizes 1, 1, 0. + final int shardSize = allPlugins.length ~/ shardCount + + (allPlugins.length % shardCount == 0 ? 0 : 1); + final int start = min(shardIndex * shardSize, allPlugins.length); + final int end = min(start + shardSize, allPlugins.length); + + for (final Directory plugin in allPlugins.sublist(start, end)) { + yield plugin; + } + } + + /// Returns the root Dart package folders of the plugins involved in this + /// command execution, assuming there is only one shard. + /// + /// Plugin packages can exist in the following places relative to the packages + /// directory: + /// + /// 1. As a Dart package in a directory which is a direct child of the + /// packages directory. This is a plugin where all of the implementations + /// exist in a single Dart package. + /// 2. Several plugin packages may live in a directory which is a direct + /// child of the packages directory. This directory groups several Dart + /// packages which implement a single plugin. This directory contains a + /// "client library" package, which declares the API for the plugin, as + /// well as one or more platform-specific implementations. + /// 3./4. Either of the above, but in a third_party/packages/ directory that + /// is a sibling of the packages directory. This is used for a small number + /// of packages in the flutter/packages repository. + Stream _getAllPlugins() async* { + Set plugins = Set.from(getStringListArg(_pluginsArg)); + final Set excludedPlugins = + Set.from(getStringListArg(_excludeArg)); + final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); + if (plugins.isEmpty && + runOnChangedPackages && + !(await _changesRequireFullTest())) { + plugins = await _getChangedPackages(); + } + + final Directory thirdPartyPackagesDirectory = packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages'); + + for (final Directory dir in [ + packagesDir, + if (thirdPartyPackagesDirectory.existsSync()) thirdPartyPackagesDirectory, + ]) { + await for (final FileSystemEntity entity + in dir.list(followLinks: false)) { + // A top-level Dart package is a plugin package. + if (_isDartPackage(entity)) { + if (!excludedPlugins.contains(entity.basename) && + (plugins.isEmpty || plugins.contains(p.basename(entity.path)))) { + yield entity as Directory; + } + } else if (entity is Directory) { + // Look for Dart packages under this top-level directory. + await for (final FileSystemEntity subdir + in entity.list(followLinks: false)) { + if (_isDartPackage(subdir)) { + // If --plugin=my_plugin is passed, then match all federated + // plugins under 'my_plugin'. Also match if the exact plugin is + // passed. + final String relativePath = + p.relative(subdir.path, from: dir.path); + final String packageName = p.basename(subdir.path); + final String basenamePath = p.basename(entity.path); + if (!excludedPlugins.contains(basenamePath) && + !excludedPlugins.contains(packageName) && + !excludedPlugins.contains(relativePath) && + (plugins.isEmpty || + plugins.contains(relativePath) || + plugins.contains(basenamePath))) { + yield subdir as Directory; + } + } + } + } + } + } + } + + /// Returns the example Dart package folders of the plugins involved in this + /// command execution. + Stream getExamples() => + getPlugins().expand(getExamplesForPlugin); + + /// Returns all Dart package folders (typically, plugin + example) of the + /// plugins involved in this command execution. + Stream getPackages() async* { + await for (final Directory plugin in getPlugins()) { + yield plugin; + yield* plugin + .list(recursive: true, followLinks: false) + .where(_isDartPackage) + .cast(); + } + } + + /// Returns the files contained, recursively, within the plugins + /// involved in this command execution. + Stream getFiles() { + return getPlugins().asyncExpand((Directory folder) => folder + .list(recursive: true, followLinks: false) + .where((FileSystemEntity entity) => entity is File) + .cast()); + } + + /// Returns whether the specified entity is a directory containing a + /// `pubspec.yaml` file. + bool _isDartPackage(FileSystemEntity entity) { + return entity is Directory && entity.childFile('pubspec.yaml').existsSync(); + } + + /// Returns the example Dart packages contained in the specified plugin, or + /// an empty List, if the plugin has no examples. + Iterable getExamplesForPlugin(Directory plugin) { + final Directory exampleFolder = plugin.childDirectory('example'); + if (!exampleFolder.existsSync()) { + return []; + } + if (isFlutterPackage(exampleFolder)) { + return [exampleFolder]; + } + // Only look at the subdirectories of the example directory if the example + // directory itself is not a Dart package, and only look one level below the + // example directory for other dart packages. + return exampleFolder + .listSync() + .where((FileSystemEntity entity) => isFlutterPackage(entity)) + .cast(); + } + + /// Retrieve an instance of [GitVersionFinder] based on `_kBaseSha` and [gitDir]. + /// + /// Throws tool exit if [gitDir] nor root directory is a git directory. + Future retrieveVersionFinder() async { + final String rootDir = packagesDir.parent.absolute.path; + final String baseSha = getStringArg(_kBaseSha); + + GitDir? baseGitDir = gitDir; + if (baseGitDir == null) { + if (!await GitDir.isGitDir(rootDir)) { + printError( + '$rootDir is not a valid Git repository.', + ); + throw ToolExit(2); + } + baseGitDir = await GitDir.fromExisting(rootDir); + } + + final GitVersionFinder gitVersionFinder = + GitVersionFinder(baseGitDir, baseSha); + return gitVersionFinder; + } + + // Returns packages that have been changed relative to the git base. + Future> _getChangedPackages() async { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + + final List allChangedFiles = + await gitVersionFinder.getChangedFiles(); + final Set packages = {}; + for (final String path in allChangedFiles) { + final List pathComponents = path.split('/'); + final int packagesIndex = + pathComponents.indexWhere((String element) => element == 'packages'); + if (packagesIndex != -1) { + packages.add(pathComponents[packagesIndex + 1]); + } + } + if (packages.isEmpty) { + print('No changed packages.'); + } else { + final String changedPackages = packages.join(','); + print('Changed packages: $changedPackages'); + } + return packages; + } + + // Returns true if one or more files changed that have the potential to affect + // any plugin (e.g., CI script changes). + Future _changesRequireFullTest() async { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + + const List specialFiles = [ + '.ci.yaml', // LUCI config. + '.cirrus.yml', // Cirrus config. + '.clang-format', // ObjC and C/C++ formatting options. + 'analysis_options.yaml', // Dart analysis settings. + ]; + const List specialDirectories = [ + '.ci/', // Support files for CI. + 'script/', // This tool, and its wrapper scripts. + ]; + // Directory entries must end with / to avoid over-matching, since the + // check below is done via string prefixing. + assert(specialDirectories.every((String dir) => dir.endsWith('/'))); + + final List allChangedFiles = + await gitVersionFinder.getChangedFiles(); + return allChangedFiles.any((String path) => + specialFiles.contains(path) || + specialDirectories.any((String dir) => path.startsWith(dir))); + } +} diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart new file mode 100644 index 000000000000..b6ac433db2e2 --- /dev/null +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -0,0 +1,110 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:yaml/yaml.dart'; + +import 'core.dart'; + +/// Possible plugin support options for a platform. +enum PlatformSupport { + /// The platform has an implementation in the package. + inline, + + /// The platform has an endorsed federated implementation in another package. + federated, +} + +/// Returns whether the given directory contains a Flutter [platform] plugin. +/// +/// It checks this by looking for the following pattern in the pubspec: +/// +/// flutter: +/// plugin: +/// platforms: +/// [platform]: +/// +/// If [requiredMode] is provided, the plugin must have the given type of +/// implementation in order to return true. +bool pluginSupportsPlatform(String platform, FileSystemEntity entity, + {PlatformSupport? requiredMode}) { + assert(platform == kPlatformFlagIos || + platform == kPlatformFlagAndroid || + platform == kPlatformFlagWeb || + platform == kPlatformFlagMacos || + platform == kPlatformFlagWindows || + platform == kPlatformFlagLinux); + if (entity is! Directory) { + return false; + } + + try { + final File pubspecFile = entity.childFile('pubspec.yaml'); + final YamlMap pubspecYaml = + loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; + if (flutterSection == null) { + return false; + } + final YamlMap? pluginSection = flutterSection['plugin'] as YamlMap?; + if (pluginSection == null) { + return false; + } + final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; + if (platforms == null) { + // Legacy plugin specs are assumed to support iOS and Android. They are + // never federated. + if (requiredMode == PlatformSupport.federated) { + return false; + } + if (!pluginSection.containsKey('platforms')) { + return platform == kPlatformFlagIos || platform == kPlatformFlagAndroid; + } + return false; + } + final YamlMap? platformEntry = platforms[platform] as YamlMap?; + if (platformEntry == null) { + return false; + } + // If the platform entry is present, then it supports the platform. Check + // for required mode if specified. + final bool federated = platformEntry.containsKey('default_package'); + return requiredMode == null || + federated == (requiredMode == PlatformSupport.federated); + } on FileSystemException { + return false; + } on YamlException { + return false; + } +} + +/// Returns whether the given directory contains a Flutter Android plugin. +bool isAndroidPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kPlatformFlagAndroid, entity); +} + +/// Returns whether the given directory contains a Flutter iOS plugin. +bool isIosPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kPlatformFlagIos, entity); +} + +/// Returns whether the given directory contains a Flutter web plugin. +bool isWebPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kPlatformFlagWeb, entity); +} + +/// Returns whether the given directory contains a Flutter Windows plugin. +bool isWindowsPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kPlatformFlagWindows, entity); +} + +/// Returns whether the given directory contains a Flutter macOS plugin. +bool isMacOsPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kPlatformFlagMacos, entity); +} + +/// Returns whether the given directory contains a Flutter linux plugin. +bool isLinuxPlugin(FileSystemEntity entity) { + return pluginSupportsPlatform(kPlatformFlagLinux, entity); +} diff --git a/script/tool/lib/src/common/process_runner.dart b/script/tool/lib/src/common/process_runner.dart new file mode 100644 index 000000000000..429761ead3b8 --- /dev/null +++ b/script/tool/lib/src/common/process_runner.dart @@ -0,0 +1,104 @@ +// Copyright 2013 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 'core.dart'; + +/// A class used to run processes. +/// +/// We use this instead of directly running the process so it can be overridden +/// in tests. +class ProcessRunner { + /// Creates a new process runner. + const ProcessRunner(); + + /// Run the [executable] with [args] and stream output to stderr and stdout. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// If [exitOnError] is set to `true`, then this will throw an error if + /// the [executable] terminates with a non-zero exit code. + /// + /// Returns the exit code of the [executable]. + Future runAndStream( + String executable, + List args, { + Directory? workingDir, + bool exitOnError = false, + }) async { + print( + 'Running command: "$executable ${args.join(' ')}" in ${workingDir?.path ?? io.Directory.current.path}'); + final io.Process process = await io.Process.start(executable, args, + workingDirectory: workingDir?.path); + await io.stdout.addStream(process.stdout); + await io.stderr.addStream(process.stderr); + if (exitOnError && await process.exitCode != 0) { + final String error = + _getErrorString(executable, args, workingDir: workingDir); + print('$error See above for details.'); + throw ToolExit(await process.exitCode); + } + return process.exitCode; + } + + /// Run the [executable] with [args]. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// If [exitOnError] is set to `true`, then this will throw an error if + /// the [executable] terminates with a non-zero exit code. + /// Defaults to `false`. + /// + /// If [logOnError] is set to `true`, it will print a formatted message about the error. + /// Defaults to `false` + /// + /// Returns the [io.ProcessResult] of the [executable]. + Future run(String executable, List args, + {Directory? workingDir, + bool exitOnError = false, + bool logOnError = false, + Encoding stdoutEncoding = io.systemEncoding, + Encoding stderrEncoding = io.systemEncoding}) async { + final io.ProcessResult result = await io.Process.run(executable, args, + workingDirectory: workingDir?.path, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding); + if (result.exitCode != 0) { + if (logOnError) { + final String error = + _getErrorString(executable, args, workingDir: workingDir); + print('$error Stderr:\n${result.stdout}'); + } + if (exitOnError) { + throw ToolExit(result.exitCode); + } + } + return result; + } + + /// Starts the [executable] with [args]. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// Returns the started [io.Process]. + Future start(String executable, List args, + {Directory? workingDirectory}) async { + final io.Process process = await io.Process.start(executable, args, + workingDirectory: workingDirectory?.path); + return process; + } + + String _getErrorString(String executable, List args, + {Directory? workingDir}) { + final String workdir = workingDir == null ? '' : ' in ${workingDir.path}'; + return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.'; + } +} diff --git a/script/tool/lib/src/common/pub_version_finder.dart b/script/tool/lib/src/common/pub_version_finder.dart new file mode 100644 index 000000000000..ebac473de7ac --- /dev/null +++ b/script/tool/lib/src/common/pub_version_finder.dart @@ -0,0 +1,103 @@ +// Copyright 2013 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 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; + +/// Finding version of [package] that is published on pub. +class PubVersionFinder { + /// Constructor. + /// + /// Note: you should manually close the [httpClient] when done using the finder. + PubVersionFinder({this.pubHost = defaultPubHost, required this.httpClient}); + + /// The default pub host to use. + static const String defaultPubHost = 'https://pub.dev'; + + /// The pub host url, defaults to `https://pub.dev`. + final String pubHost; + + /// The http client. + /// + /// You should manually close this client when done using this finder. + final http.Client httpClient; + + /// Get the package version on pub. + Future getPackageVersion( + {required String package}) async { + assert(package.isNotEmpty); + final Uri pubHostUri = Uri.parse(pubHost); + final Uri url = pubHostUri.replace(path: '/packages/$package.json'); + final http.Response response = await httpClient.get(url); + + if (response.statusCode == 404) { + return PubVersionFinderResponse( + versions: [], + result: PubVersionFinderResult.noPackageFound, + httpResponse: response); + } else if (response.statusCode != 200) { + return PubVersionFinderResponse( + versions: [], + result: PubVersionFinderResult.fail, + httpResponse: response); + } + final List versions = + (json.decode(response.body)['versions'] as List) + .map((final dynamic versionString) => + Version.parse(versionString as String)) + .toList(); + + return PubVersionFinderResponse( + versions: versions, + result: PubVersionFinderResult.success, + httpResponse: response); + } +} + +/// Represents a response for [PubVersionFinder]. +class PubVersionFinderResponse { + /// Constructor. + PubVersionFinderResponse( + {required this.versions, + required this.result, + required this.httpResponse}) { + if (versions.isNotEmpty) { + versions.sort((Version a, Version b) { + // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize]. + // https://github.com/flutter/flutter/issues/82222 + return b.compareTo(a); + }); + } + } + + /// The versions found in [PubVersionFinder]. + /// + /// This is sorted by largest to smallest, so the first element in the list is the largest version. + /// Might be `null` if the [result] is not [PubVersionFinderResult.success]. + final List versions; + + /// The result of the version finder. + final PubVersionFinderResult result; + + /// The response object of the http request. + final http.Response httpResponse; +} + +/// An enum representing the result of [PubVersionFinder]. +enum PubVersionFinderResult { + /// The version finder successfully found a version. + success, + + /// The version finder failed to find a valid version. + /// + /// This might due to http connection errors or user errors. + fail, + + /// The version finder failed to locate the package. + /// + /// This indicates the package is new. + noPackageFound, +} diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index cd5b85e45ac0..fab41bcf4ec4 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -8,7 +8,8 @@ import 'package:file/file.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; /// A command to create an application that builds all in a single application. class CreateAllPluginsAppCommand extends PluginCommand { diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 14dfede5b2f1..b6576cd13ba8 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -2,11 +2,14 @@ // 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:file/file.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; -import 'common.dart'; + +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/plugin_utils.dart'; +import 'common/process_runner.dart'; /// A command to run the example applications for packages via Flutter driver. class DriveExamplesCommand extends PluginCommand { diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 741d8569322b..b4f5e92933c6 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -9,7 +9,9 @@ import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; /// A command to run tests via Firebase test lab. class FirebaseTestLabCommand extends PluginCommand { diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index 1ef41f82bb2c..5f060d715bfd 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -2,7 +2,6 @@ // 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:convert'; import 'dart:io' as io; @@ -11,7 +10,9 @@ import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:quiver/iterables.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; final Uri _googleFormatterUrl = Uri.https('github.com', '/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar'); diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart index d1366ea7636a..d7e453b6ad74 100644 --- a/script/tool/lib/src/java_test_command.dart +++ b/script/tool/lib/src/java_test_command.dart @@ -2,12 +2,12 @@ // 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:file/file.dart'; import 'package:path/path.dart' as p; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; /// A command to run the Java tests of Android plugins. class JavaTestCommand extends PluginCommand { diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index 805c3ab9f900..4ea8a1e09392 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -2,12 +2,11 @@ // 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:file/file.dart'; import 'package:path/path.dart' as p; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; const Set _codeFileExtensions = { '.c', diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 364653bd13ba..5e86d2be40b8 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -9,7 +9,9 @@ import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; /// Lint the CocoaPod podspecs and run unit tests. /// diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart index f6b186e7ba2f..39515cf686b0 100644 --- a/script/tool/lib/src/list_command.dart +++ b/script/tool/lib/src/list_command.dart @@ -2,11 +2,9 @@ // 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:file/file.dart'; -import 'common.dart'; +import 'common/plugin_command.dart'; /// A command to list different types of repository content. class ListCommand extends PluginCommand { diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index a7603122186a..f397a04aa663 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -10,7 +10,7 @@ import 'package:file/local.dart'; import 'analyze_command.dart'; import 'build_examples_command.dart'; -import 'common.dart'; +import 'common/core.dart'; import 'create_all_plugins_app_command.dart'; import 'drive_examples_command.dart'; import 'firebase_test_lab_command.dart'; diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index b77eceecbf41..82a76609e98b 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -12,7 +12,10 @@ import 'package:http/http.dart' as http; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; +import 'common/pub_version_finder.dart'; /// A command to check that packages are publishable via 'dart publish'. class PublishCheckCommand extends PluginCommand { diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 1e7c15029846..70ec75bc7b76 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -14,7 +14,10 @@ import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:yaml/yaml.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/git_version_finder.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; @immutable class _RemoteInfo { diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 878b683dbbb8..480d3a4c1190 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -2,14 +2,14 @@ // 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:file/file.dart'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; import 'package:pubspec_parse/pubspec_parse.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; /// A command to enforce pubspec conventions across the repository. /// diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart index 0174b986eb63..b7bf261caa8a 100644 --- a/script/tool/lib/src/test_command.dart +++ b/script/tool/lib/src/test_command.dart @@ -2,12 +2,13 @@ // 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:file/file.dart'; import 'package:path/path.dart' as p; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/plugin_utils.dart'; +import 'common/process_runner.dart'; /// A command to run Dart unit tests for packages. class TestCommand extends PluginCommand { diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 6baa38e465a2..5e9f55333f8e 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -2,8 +2,6 @@ // 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:file/file.dart'; import 'package:git/git.dart'; import 'package:http/http.dart' as http; @@ -11,7 +9,11 @@ import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; -import 'common.dart'; +import 'common/core.dart'; +import 'common/git_version_finder.dart'; +import 'common/plugin_command.dart'; +import 'common/process_runner.dart'; +import 'common/pub_version_finder.dart'; /// Categories of version change types. enum NextVersionType { diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 288851ca7edf..77e5659df3f6 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -2,14 +2,16 @@ // 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:convert'; import 'dart:io' as io; import 'package:file/file.dart'; import 'package:path/path.dart' as p; -import 'common.dart'; +import 'common/core.dart'; +import 'common/plugin_command.dart'; +import 'common/plugin_utils.dart'; +import 'common/process_runner.dart'; const String _kiOSDestination = 'ios-destination'; const String _kXcodeBuildCommand = 'xcodebuild'; diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index ec627f25864c..1ef4fdc44b42 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -6,7 +6,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/analyze_command.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:test/test.dart'; import 'mocks.dart'; diff --git a/script/tool/test/common/git_version_finder_test.dart b/script/tool/test/common/git_version_finder_test.dart new file mode 100644 index 000000000000..f1f40b5e0035 --- /dev/null +++ b/script/tool/test/common/git_version_finder_test.dart @@ -0,0 +1,93 @@ +// Copyright 2013 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:io'; + +import 'package:flutter_plugin_tools/src/common/git_version_finder.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'plugin_command_test.mocks.dart'; + +void main() { + late List?> gitDirCommands; + late String gitDiffResponse; + late MockGitDir gitDir; + String? mergeBaseResponse; + + setUp(() { + gitDirCommands = ?>[]; + gitDiffResponse = ''; + gitDir = MockGitDir(); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + gitDirCommands.add(invocation.positionalArguments[0] as List?); + final MockProcessResult mockProcessResult = MockProcessResult(); + if (invocation.positionalArguments[0][0] == 'diff') { + when(mockProcessResult.stdout as String?) + .thenReturn(gitDiffResponse); + } else if (invocation.positionalArguments[0][0] == 'merge-base') { + when(mockProcessResult.stdout as String?) + .thenReturn(mergeBaseResponse); + } + return Future.value(mockProcessResult); + }); + }); + + test('No git diff should result no files changed', () async { + final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); + final List changedFiles = await finder.getChangedFiles(); + + expect(changedFiles, isEmpty); + }); + + test('get correct files changed based on git diff', () async { + gitDiffResponse = ''' +file1/file1.cc +file2/file2.cc +'''; + final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); + final List changedFiles = await finder.getChangedFiles(); + + expect(changedFiles, equals(['file1/file1.cc', 'file2/file2.cc'])); + }); + + test('get correct pubspec change based on git diff', () async { + gitDiffResponse = ''' +file1/pubspec.yaml +file2/file2.cc +'''; + final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); + final List changedFiles = await finder.getChangedPubSpecs(); + + expect(changedFiles, equals(['file1/pubspec.yaml'])); + }); + + test('use correct base sha if not specified', () async { + mergeBaseResponse = 'shaqwiueroaaidf12312jnadf123nd'; + gitDiffResponse = ''' +file1/pubspec.yaml +file2/file2.cc +'''; + + final GitVersionFinder finder = GitVersionFinder(gitDir, null); + await finder.getChangedFiles(); + verify(gitDir.runCommand( + ['diff', '--name-only', mergeBaseResponse!, 'HEAD'])); + }); + + test('use correct base sha if specified', () async { + const String customBaseSha = 'aklsjdcaskf12312'; + gitDiffResponse = ''' +file1/pubspec.yaml +file2/file2.cc +'''; + final GitVersionFinder finder = GitVersionFinder(gitDir, customBaseSha); + await finder.getChangedFiles(); + verify(gitDir + .runCommand(['diff', '--name-only', customBaseSha, 'HEAD'])); + }); +} + +class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/common_test.dart b/script/tool/test/common/plugin_command_test.dart similarity index 51% rename from script/tool/test/common_test.dart rename to script/tool/test/common/plugin_command_test.dart index a51182d91ff8..58d202e19920 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -2,24 +2,20 @@ // 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:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_command.dart'; +import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:git/git.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; -import 'common_test.mocks.dart'; -import 'util.dart'; +import '../util.dart'; +import 'plugin_command_test.mocks.dart'; @GenerateMocks([GitDir]) void main() { @@ -362,355 +358,6 @@ packages/plugin3/plugin3.dart }); }); }); - - group('$GitVersionFinder', () { - late FileSystem fileSystem; - late List?> gitDirCommands; - late String gitDiffResponse; - String? mergeBaseResponse; - late MockGitDir gitDir; - - setUp(() { - fileSystem = MemoryFileSystem(); - createPackagesDirectory(fileSystem: fileSystem); - gitDirCommands = ?>[]; - gitDiffResponse = ''; - gitDir = MockGitDir(); - when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) - .thenAnswer((Invocation invocation) { - gitDirCommands.add(invocation.positionalArguments[0] as List?); - final MockProcessResult mockProcessResult = MockProcessResult(); - if (invocation.positionalArguments[0][0] == 'diff') { - when(mockProcessResult.stdout as String?) - .thenReturn(gitDiffResponse); - } else if (invocation.positionalArguments[0][0] == 'merge-base') { - when(mockProcessResult.stdout as String?) - .thenReturn(mergeBaseResponse); - } - return Future.value(mockProcessResult); - }); - processRunner = RecordingProcessRunner(); - }); - - test('No git diff should result no files changed', () async { - final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); - final List changedFiles = await finder.getChangedFiles(); - - expect(changedFiles, isEmpty); - }); - - test('get correct files changed based on git diff', () async { - gitDiffResponse = ''' -file1/file1.cc -file2/file2.cc -'''; - final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); - final List changedFiles = await finder.getChangedFiles(); - - expect( - changedFiles, equals(['file1/file1.cc', 'file2/file2.cc'])); - }); - - test('get correct pubspec change based on git diff', () async { - gitDiffResponse = ''' -file1/pubspec.yaml -file2/file2.cc -'''; - final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); - final List changedFiles = await finder.getChangedPubSpecs(); - - expect(changedFiles, equals(['file1/pubspec.yaml'])); - }); - - test('use correct base sha if not specified', () async { - mergeBaseResponse = 'shaqwiueroaaidf12312jnadf123nd'; - gitDiffResponse = ''' -file1/pubspec.yaml -file2/file2.cc -'''; - - final GitVersionFinder finder = GitVersionFinder(gitDir, null); - await finder.getChangedFiles(); - verify(gitDir.runCommand( - ['diff', '--name-only', mergeBaseResponse!, 'HEAD'])); - }); - - test('use correct base sha if specified', () async { - const String customBaseSha = 'aklsjdcaskf12312'; - gitDiffResponse = ''' -file1/pubspec.yaml -file2/file2.cc -'''; - final GitVersionFinder finder = GitVersionFinder(gitDir, customBaseSha); - await finder.getChangedFiles(); - verify(gitDir - .runCommand(['diff', '--name-only', customBaseSha, 'HEAD'])); - }); - }); - - group('$PubVersionFinder', () { - test('Package does not exist.', () async { - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response('', 404); - }); - final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); - final PubVersionFinderResponse response = - await finder.getPackageVersion(package: 'some_package'); - - expect(response.versions, isEmpty); - expect(response.result, PubVersionFinderResult.noPackageFound); - expect(response.httpResponse.statusCode, 404); - expect(response.httpResponse.body, ''); - }); - - test('HTTP error when getting versions from pub', () async { - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response('', 400); - }); - final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); - final PubVersionFinderResponse response = - await finder.getPackageVersion(package: 'some_package'); - - expect(response.versions, isEmpty); - expect(response.result, PubVersionFinderResult.fail); - expect(response.httpResponse.statusCode, 400); - expect(response.httpResponse.body, ''); - }); - - test('Get a correct list of versions when http response is OK.', () async { - const Map httpResponse = { - 'name': 'some_package', - 'versions': [ - '0.0.1', - '0.0.2', - '0.0.2+2', - '0.1.1', - '0.0.1+1', - '0.1.0', - '0.2.0', - '0.1.0+1', - '0.0.2+1', - '2.0.0', - '1.2.0', - '1.0.0', - ], - }; - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response(json.encode(httpResponse), 200); - }); - final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); - final PubVersionFinderResponse response = - await finder.getPackageVersion(package: 'some_package'); - - expect(response.versions, [ - Version.parse('2.0.0'), - Version.parse('1.2.0'), - Version.parse('1.0.0'), - Version.parse('0.2.0'), - Version.parse('0.1.1'), - Version.parse('0.1.0+1'), - Version.parse('0.1.0'), - Version.parse('0.0.2+2'), - Version.parse('0.0.2+1'), - Version.parse('0.0.2'), - Version.parse('0.0.1+1'), - Version.parse('0.0.1'), - ]); - expect(response.result, PubVersionFinderResult.success); - expect(response.httpResponse.statusCode, 200); - expect(response.httpResponse.body, json.encode(httpResponse)); - }); - }); - - group('pluginSupportsPlatform', () { - test('no platforms', () async { - final Directory plugin = createFakePlugin('plugin', packagesDir); - - expect(pluginSupportsPlatform('android', plugin), isFalse); - expect(pluginSupportsPlatform('ios', plugin), isFalse); - expect(pluginSupportsPlatform('linux', plugin), isFalse); - expect(pluginSupportsPlatform('macos', plugin), isFalse); - expect(pluginSupportsPlatform('web', plugin), isFalse); - expect(pluginSupportsPlatform('windows', plugin), isFalse); - }); - - test('all platforms', () async { - final Directory plugin = createFakePlugin( - 'plugin', - packagesDir, - isAndroidPlugin: true, - isIosPlugin: true, - isLinuxPlugin: true, - isMacOsPlugin: true, - isWebPlugin: true, - isWindowsPlugin: true, - ); - - expect(pluginSupportsPlatform('android', plugin), isTrue); - expect(pluginSupportsPlatform('ios', plugin), isTrue); - expect(pluginSupportsPlatform('linux', plugin), isTrue); - expect(pluginSupportsPlatform('macos', plugin), isTrue); - expect(pluginSupportsPlatform('web', plugin), isTrue); - expect(pluginSupportsPlatform('windows', plugin), isTrue); - }); - - test('some platforms', () async { - final Directory plugin = createFakePlugin( - 'plugin', - packagesDir, - isAndroidPlugin: true, - isIosPlugin: false, - isLinuxPlugin: true, - isMacOsPlugin: false, - isWebPlugin: true, - isWindowsPlugin: false, - ); - - expect(pluginSupportsPlatform('android', plugin), isTrue); - expect(pluginSupportsPlatform('ios', plugin), isFalse); - expect(pluginSupportsPlatform('linux', plugin), isTrue); - expect(pluginSupportsPlatform('macos', plugin), isFalse); - expect(pluginSupportsPlatform('web', plugin), isTrue); - expect(pluginSupportsPlatform('windows', plugin), isFalse); - }); - - test('inline plugins are only detected as inline', () async { - // createFakePlugin makes non-federated pubspec entries. - final Directory plugin = createFakePlugin( - 'plugin', - packagesDir, - isAndroidPlugin: true, - isIosPlugin: true, - isLinuxPlugin: true, - isMacOsPlugin: true, - isWebPlugin: true, - isWindowsPlugin: true, - ); - - expect( - pluginSupportsPlatform('android', plugin, - requiredMode: PlatformSupport.inline), - isTrue); - expect( - pluginSupportsPlatform('android', plugin, - requiredMode: PlatformSupport.federated), - isFalse); - expect( - pluginSupportsPlatform('ios', plugin, - requiredMode: PlatformSupport.inline), - isTrue); - expect( - pluginSupportsPlatform('ios', plugin, - requiredMode: PlatformSupport.federated), - isFalse); - expect( - pluginSupportsPlatform('linux', plugin, - requiredMode: PlatformSupport.inline), - isTrue); - expect( - pluginSupportsPlatform('linux', plugin, - requiredMode: PlatformSupport.federated), - isFalse); - expect( - pluginSupportsPlatform('macos', plugin, - requiredMode: PlatformSupport.inline), - isTrue); - expect( - pluginSupportsPlatform('macos', plugin, - requiredMode: PlatformSupport.federated), - isFalse); - expect( - pluginSupportsPlatform('web', plugin, - requiredMode: PlatformSupport.inline), - isTrue); - expect( - pluginSupportsPlatform('web', plugin, - requiredMode: PlatformSupport.federated), - isFalse); - expect( - pluginSupportsPlatform('windows', plugin, - requiredMode: PlatformSupport.inline), - isTrue); - expect( - pluginSupportsPlatform('windows', plugin, - requiredMode: PlatformSupport.federated), - isFalse); - }); - - test('federated plugins are only detected as federated', () async { - const String pluginName = 'plugin'; - final Directory plugin = createFakePlugin( - pluginName, - packagesDir, - isAndroidPlugin: true, - isIosPlugin: true, - isLinuxPlugin: true, - isMacOsPlugin: true, - isWebPlugin: true, - isWindowsPlugin: true, - ); - - createFakePubspec( - plugin, - name: pluginName, - androidSupport: PlatformSupport.federated, - iosSupport: PlatformSupport.federated, - linuxSupport: PlatformSupport.federated, - macosSupport: PlatformSupport.federated, - webSupport: PlatformSupport.federated, - windowsSupport: PlatformSupport.federated, - ); - - expect( - pluginSupportsPlatform('android', plugin, - requiredMode: PlatformSupport.federated), - isTrue); - expect( - pluginSupportsPlatform('android', plugin, - requiredMode: PlatformSupport.inline), - isFalse); - expect( - pluginSupportsPlatform('ios', plugin, - requiredMode: PlatformSupport.federated), - isTrue); - expect( - pluginSupportsPlatform('ios', plugin, - requiredMode: PlatformSupport.inline), - isFalse); - expect( - pluginSupportsPlatform('linux', plugin, - requiredMode: PlatformSupport.federated), - isTrue); - expect( - pluginSupportsPlatform('linux', plugin, - requiredMode: PlatformSupport.inline), - isFalse); - expect( - pluginSupportsPlatform('macos', plugin, - requiredMode: PlatformSupport.federated), - isTrue); - expect( - pluginSupportsPlatform('macos', plugin, - requiredMode: PlatformSupport.inline), - isFalse); - expect( - pluginSupportsPlatform('web', plugin, - requiredMode: PlatformSupport.federated), - isTrue); - expect( - pluginSupportsPlatform('web', plugin, - requiredMode: PlatformSupport.inline), - isFalse); - expect( - pluginSupportsPlatform('windows', plugin, - requiredMode: PlatformSupport.federated), - isTrue); - expect( - pluginSupportsPlatform('windows', plugin, - requiredMode: PlatformSupport.inline), - isFalse); - }); - }); } class SamplePluginCommand extends PluginCommand { diff --git a/script/tool/test/common_test.mocks.dart b/script/tool/test/common/plugin_command_test.mocks.dart similarity index 100% rename from script/tool/test/common_test.mocks.dart rename to script/tool/test/common/plugin_command_test.mocks.dart diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart new file mode 100644 index 000000000000..aaa850155da4 --- /dev/null +++ b/script/tool/test/common/plugin_utils_test.dart @@ -0,0 +1,210 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:test/test.dart'; + +import '../util.dart'; + +void main() { + late FileSystem fileSystem; + late Directory packagesDir; + + setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + }); + + group('pluginSupportsPlatform', () { + test('no platforms', () async { + final Directory plugin = createFakePlugin('plugin', packagesDir); + + expect(pluginSupportsPlatform('android', plugin), isFalse); + expect(pluginSupportsPlatform('ios', plugin), isFalse); + expect(pluginSupportsPlatform('linux', plugin), isFalse); + expect(pluginSupportsPlatform('macos', plugin), isFalse); + expect(pluginSupportsPlatform('web', plugin), isFalse); + expect(pluginSupportsPlatform('windows', plugin), isFalse); + }); + + test('all platforms', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + isAndroidPlugin: true, + isIosPlugin: true, + isLinuxPlugin: true, + isMacOsPlugin: true, + isWebPlugin: true, + isWindowsPlugin: true, + ); + + expect(pluginSupportsPlatform('android', plugin), isTrue); + expect(pluginSupportsPlatform('ios', plugin), isTrue); + expect(pluginSupportsPlatform('linux', plugin), isTrue); + expect(pluginSupportsPlatform('macos', plugin), isTrue); + expect(pluginSupportsPlatform('web', plugin), isTrue); + expect(pluginSupportsPlatform('windows', plugin), isTrue); + }); + + test('some platforms', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + isAndroidPlugin: true, + isIosPlugin: false, + isLinuxPlugin: true, + isMacOsPlugin: false, + isWebPlugin: true, + isWindowsPlugin: false, + ); + + expect(pluginSupportsPlatform('android', plugin), isTrue); + expect(pluginSupportsPlatform('ios', plugin), isFalse); + expect(pluginSupportsPlatform('linux', plugin), isTrue); + expect(pluginSupportsPlatform('macos', plugin), isFalse); + expect(pluginSupportsPlatform('web', plugin), isTrue); + expect(pluginSupportsPlatform('windows', plugin), isFalse); + }); + + test('inline plugins are only detected as inline', () async { + // createFakePlugin makes non-federated pubspec entries. + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + isAndroidPlugin: true, + isIosPlugin: true, + isLinuxPlugin: true, + isMacOsPlugin: true, + isWebPlugin: true, + isWindowsPlugin: true, + ); + + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.federated), + isFalse); + }); + + test('federated plugins are only detected as federated', () async { + const String pluginName = 'plugin'; + final Directory plugin = createFakePlugin( + pluginName, + packagesDir, + isAndroidPlugin: true, + isIosPlugin: true, + isLinuxPlugin: true, + isMacOsPlugin: true, + isWebPlugin: true, + isWindowsPlugin: true, + ); + + createFakePubspec( + plugin, + name: pluginName, + androidSupport: PlatformSupport.federated, + iosSupport: PlatformSupport.federated, + linuxSupport: PlatformSupport.federated, + macosSupport: PlatformSupport.federated, + webSupport: PlatformSupport.federated, + windowsSupport: PlatformSupport.federated, + ); + + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('android', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('ios', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('linux', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('macos', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('web', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform('windows', plugin, + requiredMode: PlatformSupport.inline), + isFalse); + }); + }); +} diff --git a/script/tool/test/common/pub_version_finder_test.dart b/script/tool/test/common/pub_version_finder_test.dart new file mode 100644 index 000000000000..7d8658a907ee --- /dev/null +++ b/script/tool/test/common/pub_version_finder_test.dart @@ -0,0 +1,89 @@ +// Copyright 2013 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'; + +import 'package:flutter_plugin_tools/src/common/pub_version_finder.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + test('Package does not exist.', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 404); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, isEmpty); + expect(response.result, PubVersionFinderResult.noPackageFound); + expect(response.httpResponse.statusCode, 404); + expect(response.httpResponse.body, ''); + }); + + test('HTTP error when getting versions from pub', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 400); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, isEmpty); + expect(response.result, PubVersionFinderResult.fail); + expect(response.httpResponse.statusCode, 400); + expect(response.httpResponse.body, ''); + }); + + test('Get a correct list of versions when http response is OK.', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '0.0.2+2', + '0.1.1', + '0.0.1+1', + '0.1.0', + '0.2.0', + '0.1.0+1', + '0.0.2+1', + '2.0.0', + '1.2.0', + '1.0.0', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, [ + Version.parse('2.0.0'), + Version.parse('1.2.0'), + Version.parse('1.0.0'), + Version.parse('0.2.0'), + Version.parse('0.1.1'), + Version.parse('0.1.0+1'), + Version.parse('0.1.0'), + Version.parse('0.0.2+2'), + Version.parse('0.0.2+1'), + Version.parse('0.0.2'), + Version.parse('0.0.1+1'), + Version.parse('0.0.1'), + ]); + expect(response.result, PubVersionFinderResult.success); + expect(response.httpResponse.statusCode, 200); + expect(response.httpResponse.body, json.encode(httpResponse)); + }); +} + +class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index c9a8b9d90a83..9c5bd18cfb11 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -5,7 +5,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/drive_examples_command.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart index aa8be17d6794..0bc8f1e197c6 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_test.dart @@ -7,7 +7,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/firebase_test_lab_command.dart'; import 'package:test/test.dart'; diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart index a874d7db17b7..dfe8d25197ab 100644 --- a/script/tool/test/license_check_command_test.dart +++ b/script/tool/test/license_check_command_test.dart @@ -5,7 +5,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/license_check_command.dart'; import 'package:test/test.dart'; diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index e5722567f20c..c0ccd2989cf9 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -9,7 +9,7 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/publish_check_command.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 1cb4245fdb73..ef682bfe61f6 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -9,7 +9,8 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; import 'package:git/git.dart'; import 'package:mockito/mockito.dart'; diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 576060d23a9d..f5fe6aef849a 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -5,7 +5,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/pubspec_check_command.dart'; import 'package:test/test.dart'; diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index c590d8a4bb04..79c46fcc50e5 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -9,7 +9,8 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:meta/meta.dart'; import 'package:quiver/collection.dart'; diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index 1199c270642f..a8e7e20bad24 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -9,7 +9,7 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/version_check_command.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -17,7 +17,7 @@ import 'package:mockito/mockito.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; -import 'common_test.mocks.dart'; +import 'common/plugin_command_test.mocks.dart'; import 'util.dart'; void testAllowedVersion( diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 8ed8144562c9..c0bd6b5dee50 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -7,7 +7,8 @@ import 'dart:convert'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/xctest_command.dart'; import 'package:test/test.dart'; From da401ba248dfe9eee93444d5bf7f28be72a9b042 Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Wed, 16 Jun 2021 12:44:03 -0700 Subject: [PATCH 044/364] Support Hybrid Composition on Android (#4017) --- .../test/google_map_test.dart | 30 ++++++ .../CHANGELOG.md | 5 + ...oogle_maps_flutter_platform_interface.dart | 2 + .../method_channel_google_maps_flutter.dart | 93 +++++++++++++++++++ .../pubspec.yaml | 2 +- 5 files changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart index 2b754afbd359..d1ec87a4730d 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart @@ -587,4 +587,34 @@ void main() { expect(platformGoogleMap.buildingsEnabled, true); }); + + testWidgets( + 'Default Android widget is AndroidView', + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: CameraPosition(target: LatLng(10.0, 15.0)), + ), + ), + ); + + expect(find.byType(AndroidView), findsOneWidget); + }, + ); + + // TODO(bparrishMines): Uncomment once https://github.com/flutter/plugins/pull/4017 has landed. + // testWidgets('Use AndroidViewSurface on Android', (WidgetTester tester) async { + // await tester.pumpWidget( + // const Directionality( + // textDirection: TextDirection.ltr, + // child: GoogleMap( + // initialCameraPosition: CameraPosition(target: LatLng(10.0, 15.0)), + // ), + // ), + // ); + // + // expect(find.byType(AndroidViewSurface), findsOneWidget); + // }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md index b6603d66fa89..2dc533fe1dfa 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.1.0 + +* Add support for Hybrid Composition when building the Google Maps widget on Android. Set + `MethodChannelGoogleMapsFlutter.useAndroidViewSurface` to `true` to build with Hybrid Composition. + ## 2.0.4 * Preserve the `TileProvider` when copying `TileOverlay`, fixing a diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart index 650a839cb676..300700071102 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/method_channel/method_channel_google_maps_flutter.dart' + show MethodChannelGoogleMapsFlutter; export 'src/platform_interface/google_maps_flutter_platform.dart'; export 'src/types/types.dart'; export 'src/events/map_event.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart index 49029cc3d22d..41aedc759b15 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -441,6 +442,98 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { return channel(mapId).invokeMethod('map#takeSnapshot'); } + /// Set [GoogleMapsFlutterPlatform] to use [AndroidViewSurface] to build the Google Maps widget. + /// + /// This implementation uses hybrid composition to render the Google Maps + /// Widget on Android. This comes at the cost of some performance on Android + /// versions below 10. See + /// https://flutter.dev/docs/development/platform-integration/platform-views#performance for more + /// information. + /// + /// If set to true, the google map widget should be built with + /// [buildViewWithTextDirection] instead of [buildView]. + /// + /// Defaults to false. + bool useAndroidViewSurface = false; + + /// Returns a widget displaying the map view. + /// + /// This method includes a parameter for platforms that require a text + /// direction. For example, this should be used when using hybrid composition + /// on Android. + Widget buildViewWithTextDirection( + int creationId, + PlatformViewCreatedCallback onPlatformViewCreated, { + required CameraPosition initialCameraPosition, + required TextDirection textDirection, + Set markers = const {}, + Set polygons = const {}, + Set polylines = const {}, + Set circles = const {}, + Set tileOverlays = const {}, + Set>? gestureRecognizers, + Map mapOptions = const {}, + }) { + if (defaultTargetPlatform == TargetPlatform.android && + useAndroidViewSurface) { + final Map creationParams = { + 'initialCameraPosition': initialCameraPosition.toMap(), + 'options': mapOptions, + 'markersToAdd': serializeMarkerSet(markers), + 'polygonsToAdd': serializePolygonSet(polygons), + 'polylinesToAdd': serializePolylineSet(polylines), + 'circlesToAdd': serializeCircleSet(circles), + 'tileOverlaysToAdd': serializeTileOverlaySet(tileOverlays), + }; + return PlatformViewLink( + viewType: 'plugins.flutter.io/google_maps', + surfaceFactory: ( + BuildContext context, + PlatformViewController controller, + ) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: gestureRecognizers ?? + const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (PlatformViewCreationParams params) { + final SurfaceAndroidViewController controller = + PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: 'plugins.flutter.io/google_maps', + layoutDirection: textDirection, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ); + controller.addOnPlatformViewCreatedListener( + params.onPlatformViewCreated, + ); + controller.addOnPlatformViewCreatedListener( + onPlatformViewCreated, + ); + + controller.create(); + return controller; + }, + ); + } + return buildView( + creationId, + onPlatformViewCreated, + initialCameraPosition: initialCameraPosition, + markers: markers, + polygons: polygons, + polylines: polylines, + circles: circles, + tileOverlays: tileOverlays, + gestureRecognizers: gestureRecognizers, + mapOptions: mapOptions, + ); + } + @override Widget buildView( int creationId, diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml index 5b278a812a8e..1ea425ea0273 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.0.4 +version: 2.1.0 environment: sdk: '>=2.12.0 <3.0.0' From 35fba6a08a3f7284019575a4c6fa2a5cfce2933a Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 16 Jun 2021 17:22:29 -0700 Subject: [PATCH 045/364] Pin base image used in CI Dockerfile (#4060) Re-merges the Dockerfiles now that https://github.com/flutter/flutter/issues/84717 has been addressed, and ensures that we are controlling the base image that we use, rather than it changing automatically on rebuild. Part of https://github.com/flutter/flutter/issues/84712 --- .ci/Dockerfile | 27 +++++++++++++++++---------- .cirrus.yml | 28 ++++------------------------ 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/.ci/Dockerfile b/.ci/Dockerfile index 13fd48be9c14..a3deb6948d90 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,8 +1,15 @@ -FROM cirrusci/flutter:stable +# The Flutter version is not important here, since the CI scripts update Flutter +# before running. What matters is that the base image is pinned to minimize +# unintended changes when modifying this file. +FROM cirrusci/flutter:2.2.2 -RUN sudo apt-get update -y +RUN apt-get update -y -RUN sudo apt-get install -y --no-install-recommends gnupg +# Required by Roboeletric and the Android SDK. +RUN apt-get install -y openjdk-8-jdk +ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 + +RUN apt-get install -y --no-install-recommends gnupg # Add repo for gcloud sdk and install it RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | \ @@ -11,7 +18,7 @@ RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages. RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | \ sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - -RUN sudo apt-get update && sudo apt-get install -y google-cloud-sdk && \ +RUN apt-get update && apt-get install -y google-cloud-sdk && \ gcloud config set core/disable_usage_reporting true && \ gcloud config set component_manager/disable_update_check true @@ -24,20 +31,20 @@ RUN yes | sdkmanager \ RUN yes | sdkmanager --licenses # Install formatter. -RUN sudo apt-get install -y clang-format +RUN apt-get install -y clang-format # Install xvfb to allow running headless -RUN sudo apt-get install -y xvfb libegl1-mesa +RUN apt-get install -y xvfb libegl1-mesa # Install Linux desktop build tool requirements. -RUN sudo apt-get install -y clang cmake ninja-build file pkg-config +RUN apt-get install -y clang cmake ninja-build file pkg-config # Install necessary libraries. -RUN sudo apt-get install -y libgtk-3-dev libblkid-dev liblzma-dev libgcrypt20-dev +RUN apt-get install -y libgtk-3-dev libblkid-dev liblzma-dev libgcrypt20-dev # Add repo for Google Chrome and install it RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - RUN echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list -RUN sudo apt-get update && sudo apt-get install -y --no-install-recommends google-chrome-stable +RUN apt-get update && apt-get install -y --no-install-recommends google-chrome-stable # Make Chrome the default so http: has a handler for url_launcher tests. -RUN sudo apt-get install -y xdg-utils +RUN apt-get install -y xdg-utils RUN xdg-settings set default-web-browser google-chrome.desktop diff --git a/.cirrus.yml b/.cirrus.yml index dcec7bc84ca3..0ba7fe3d5264 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -39,7 +39,7 @@ task: << : *FLUTTER_UPGRADE_TEMPLATE gke_container: dockerfile: .ci/Dockerfile - builder_image_name: docker-builder # gce vm image + builder_image_name: docker-builder-linux # gce vm image builder_image_project: flutter-cirrus cluster_name: test-cluster zone: us-central1-a @@ -112,18 +112,14 @@ task: drive_script: - xvfb-run ./script/tool_runner.sh drive-examples --linux -# Heavy-workload Android tasks. +# Heavy-workload Linux tasks. # These use machines with more CPUs and memory, so will reduce parallelization # for non-credit runs. -# -# This has been temporarily split out from the other heavy tasks to use a -# different Docker image due to space constraints; see -# https://github.com/flutter/flutter/issues/84706 task: << : *FLUTTER_UPGRADE_TEMPLATE gke_container: - dockerfile: .ci/java8.Dockerfile - builder_image_name: docker-builder # gce vm image + dockerfile: .ci/Dockerfile + builder_image_name: docker-builder-linux # gce vm image builder_image_project: flutter-cirrus cluster_name: test-cluster zone: us-central1-a @@ -165,22 +161,6 @@ task: - fi - export CIRRUS_CHANGE_MESSAGE=`cat /tmp/cirrus_change_message.txt` - export CIRRUS_COMMIT_MESSAGE=`cat /tmp/cirrus_commit_message.txt` - -# Heavy-workload web tasks. -# These use machines with more CPUs and memory, so will reduce parallelization -# for non-credit runs. -task: - << : *FLUTTER_UPGRADE_TEMPLATE - gke_container: - dockerfile: .ci/Dockerfile - builder_image_name: docker-builder # gce vm image - builder_image_project: flutter-cirrus - cluster_name: test-cluster - zone: us-central1-a - namespace: default - cpu: 4 - memory: 12G - matrix: ### Web tasks ### - name: build-web+drive-examples env: From d850031c7ce425eb6393ebfdd48144e60cca873d Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Thu, 17 Jun 2021 19:59:03 +0300 Subject: [PATCH 046/364] [image_picker] Migrate README example to null-safety (#4038) --- .../image_picker/image_picker/CHANGELOG.md | 4 ++ packages/image_picker/image_picker/README.md | 57 +------------------ .../image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 55 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index b913bbf29758..d9127e24d2af 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.0+4 + +* Cleaned up the README example + ## 0.8.0+3 * Readded request for camera permissions. diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index 1de12bc556d9..10899e2d85fb 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -29,62 +29,11 @@ If you require your picked image to be stored permanently, it is your responsibi ### Example ``` dart -import 'dart:io'; - -import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -void main() => runApp(MyApp()); - -class MyApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return MaterialApp( - home: MyHomePage(), - ); - } -} - -class MyHomePage extends StatefulWidget { - @override - _MyHomePageState createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - File _image; - final picker = ImagePicker(); - - Future getImage() async { - final pickedFile = await picker.getImage(source: ImageSource.camera); - - setState(() { - if (pickedFile != null) { - _image = File(pickedFile.path); - } else { - print('No image selected.'); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('Image Picker Example'), - ), - body: Center( - child: _image == null - ? Text('No image selected.') - : Image.file(_image), - ), - floatingActionButton: FloatingActionButton( - onPressed: getImage, - tooltip: 'Pick Image', - child: Icon(Icons.add_a_photo), - ), - ); - } -} + ... + final PickedFile? pickedFile = await picker.getImage(source: ImageSource.camera); + ... ``` ### Handling MainActivity destruction on Android diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index bf42015e3193..4dc7785111a4 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.0+3 +version: 0.8.0+4 environment: sdk: ">=2.12.0 <3.0.0" From 9b7b6d86f32c9222ba8f0e4dd2b165e07bc3b01a Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Thu, 17 Jun 2021 20:04:03 +0300 Subject: [PATCH 047/364] [in_app_purchase_platform_interface] Fixed restoring purchases link (#4051) --- .../in_app_purchase_platform_interface/CHANGELOG.md | 4 ++++ .../lib/src/types/purchase_status.dart | 2 +- .../in_app_purchase_platform_interface/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md index 2f529b31655d..15978f3756ef 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.1 + +* Fixed `Restoring previous purchases` link. + ## 1.0.0 * Initial open-source release. \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_status.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_status.dart index 69f31c8f0641..78695066702d 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_status.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_status.dart @@ -24,6 +24,6 @@ enum PurchaseStatus { /// You should validate the purchase and if valid deliver the content. Once the /// content has been delivered or if the receipt is invalid you should finish /// the purchase by calling the `completePurchase` method. More information on - /// verifying purchases can be found [here](https://pub.dev/packages/in_app_purchase#loading-previous-purchases). + /// verifying purchases can be found [here](https://pub.dev/packages/in_app_purchase#restoring-previous-purchases). restored, } diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml index ce0357dc1919..a5be5a005e2c 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purch issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 1.0.0 +version: 1.0.1 environment: sdk: ">=2.12.0 <3.0.0" From 96c9d0be2f514b1145bfcf28f2c19a04625d60aa Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 17 Jun 2021 11:39:03 -0700 Subject: [PATCH 048/364] [integration_test] Update README installation instructions (#4062) --- packages/integration_test/README.md | 70 ++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/integration_test/README.md b/packages/integration_test/README.md index 802fd4cafefc..67f658b56327 100644 --- a/packages/integration_test/README.md +++ b/packages/integration_test/README.md @@ -57,7 +57,7 @@ Future main() => integrationDriver(); You can also use different driver scripts to customize the behavior of the app under test. For example, `FlutterDriver` can also be parameterized with different [options](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/connect.html). -See the [extended driver](https://github.com/flutter/plugins/blob/master/packages/integration_test/example/test_driver/extended_integration_test.dart) for an example. +See the [extended driver](https://github.com/flutter/flutter/blob/master/packages/integration_test/example/test_driver/extended_integration_test.dart) for an example. ### Package Structure @@ -195,33 +195,71 @@ devices you want to test on. See ## iOS Device Testing -You need to change `iOS/Podfile` to avoid test target statically linking to the plugins. One way is to -link all of the plugins dynamically: +Open `ios/Runner.xcworkspace` in Xcode. Create a test target if you +do not already have one via `File > New > Target...` and select `Unit Testing Bundle`. +Change the `Product Name` to `RunnerTests`. Make sure `Target to be Tested` is set to `Runner` and language is set to `Objective-C`. +Select `Finish`. +Make sure that the **iOS Deployment Target** of `RunnerTests` within the **Build Settings** section is the same as `Runner`. -``` +Add the new test target to `ios/Podfile` by embedding in the existing `Runner` target. + +```ruby target 'Runner' do - use_frameworks! + # Do not change existing lines. ... + + target 'RunnerTests' do + inherit! :search_paths + end end ``` -To run `integration_test/foo_test.dart` on your iOS device, rebuild your iOS -targets with Flutter tool. - +To build `integration_test/foo_test.dart` from the command line, run: ```sh -# Pass --simulator if building for the simulator. -flutter build ios integration_test/foo_test.dart +flutter build ios --config-only integration_test/foo_test.dart ``` -Open Xcode project (by default, it's `ios/Runner.xcodeproj`). Create a test target -(navigating `File > New > Target...` and set up the values) and a test file `RunnerTests.m` and -change the code. You can change `RunnerTests.m` to the name of your choice. +In Xcode, add a test file called `RunnerTests.m` (or any name of your choice) to the new target and +replace the file: ```objective-c -#import -#import +@import XCTest; +@import integration_test; INTEGRATION_TEST_IOS_RUNNER(RunnerTests) ``` -Now you can start RunnerTests to kick-off integration tests! +Run `Product > Test` to run the integration tests on your selected device. + +To deploy it to Firebase Test Lab you can follow these steps: + +Execute this script at the root of your Flutter app: + +```sh +output="../build/ios_integ" +product="build/ios_integ/Build/Products" +dev_target="14.3" + +# Pass --simulator if building for the simulator. +flutter build ios integration_test/foo_test.dart --release + +pushd ios +xcodebuild -workspace Runner.xcworkspace -scheme Runner -config Flutter/Release.xcconfig -derivedDataPath $output -sdk iphoneos build-for-testing +popd + +pushd $product +zip -r "ios_tests.zip" "Release-iphoneos" "Runner_iphoneos$dev_target-arm64.xctestrun" +popd +``` + +You can verify locally that your tests are successful by running the following command: + +```sh +xcodebuild test-without-building -xctestrun "build/ios_integ/Build/Products/Runner_iphoneos14.3-arm64.xctestrun" -destination id= +``` + +Once everything is ok, you can upload the resulting zip to Firebase Test Lab (change the model with your values): + +```sh +gcloud firebase test ios run --test "build/ios_integ/ios_tests.zip" --device model=iphone11pro,version=14.1,locale=fr_FR,orientation=portrait +``` From a5d642dc7642018f78abc8e37697cd2485818e60 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 17 Jun 2021 13:29:03 -0700 Subject: [PATCH 049/364] [flutter_plugin_tool] Refactor createFakePlugin (#4064) --- .../tool/lib/src/build_examples_command.dart | 32 +-- script/tool/lib/src/common/core.dart | 12 +- script/tool/lib/src/common/plugin_utils.dart | 26 +- .../tool/lib/src/drive_examples_command.dart | 32 +-- script/tool/lib/src/xctest_command.dart | 8 +- script/tool/test/analyze_command_test.dart | 11 +- .../test/build_examples_command_test.dart | 174 ++++++------ .../tool/test/common/plugin_command_test.dart | 18 +- .../tool/test/common/plugin_utils_test.dart | 155 ++++++----- .../test/drive_examples_command_test.dart | 248 +++++++----------- script/tool/test/firebase_test_lab_test.dart | 6 +- script/tool/test/java_test_command_test.dart | 18 +- .../tool/test/lint_podspecs_command_test.dart | 24 +- script/tool/test/list_command_test.dart | 21 +- .../tool/test/publish_check_command_test.dart | 33 +-- .../test/publish_plugin_command_test.dart | 108 +++----- .../tool/test/pubspec_check_command_test.dart | 30 +-- script/tool/test/test_command_test.dart | 81 +++--- script/tool/test/util.dart | 159 +++++------ script/tool/test/version_check_test.dart | 149 +++++------ script/tool/test/xctest_command_test.dart | 112 +++----- 21 files changed, 638 insertions(+), 819 deletions(-) diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 61d291d87c68..aff5ecba4989 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -27,10 +27,10 @@ class BuildExamplesCommand extends PluginCommand { Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), }) : super(packagesDir, processRunner: processRunner) { - argParser.addFlag(kPlatformFlagLinux, defaultsTo: false); - argParser.addFlag(kPlatformFlagMacos, defaultsTo: false); - argParser.addFlag(kPlatformFlagWeb, defaultsTo: false); - argParser.addFlag(kPlatformFlagWindows, defaultsTo: false); + argParser.addFlag(kPlatformLinux, defaultsTo: false); + argParser.addFlag(kPlatformMacos, defaultsTo: false); + argParser.addFlag(kPlatformWeb, defaultsTo: false); + argParser.addFlag(kPlatformWindows, defaultsTo: false); argParser.addFlag(kIpa, defaultsTo: io.Platform.isMacOS); argParser.addFlag(kApk); argParser.addOption( @@ -53,10 +53,10 @@ class BuildExamplesCommand extends PluginCommand { final List platformSwitches = [ kApk, kIpa, - kPlatformFlagLinux, - kPlatformFlagMacos, - kPlatformFlagWeb, - kPlatformFlagWindows, + kPlatformLinux, + kPlatformMacos, + kPlatformWeb, + kPlatformWindows, ]; if (!platformSwitches.any((String platform) => getBoolArg(platform))) { print( @@ -75,14 +75,14 @@ class BuildExamplesCommand extends PluginCommand { final String packageName = p.relative(example.path, from: packagesDir.path); - if (getBoolArg(kPlatformFlagLinux)) { + if (getBoolArg(kPlatformLinux)) { print('\nBUILDING Linux for $packageName'); if (isLinuxPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kPlatformFlagLinux, + kPlatformLinux, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], @@ -95,14 +95,14 @@ class BuildExamplesCommand extends PluginCommand { } } - if (getBoolArg(kPlatformFlagMacos)) { + if (getBoolArg(kPlatformMacos)) { print('\nBUILDING macOS for $packageName'); if (isMacOsPlugin(plugin)) { final int exitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kPlatformFlagMacos, + kPlatformMacos, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], @@ -115,14 +115,14 @@ class BuildExamplesCommand extends PluginCommand { } } - if (getBoolArg(kPlatformFlagWeb)) { + if (getBoolArg(kPlatformWeb)) { print('\nBUILDING web for $packageName'); if (isWebPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kPlatformFlagWeb, + kPlatformWeb, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], @@ -135,14 +135,14 @@ class BuildExamplesCommand extends PluginCommand { } } - if (getBoolArg(kPlatformFlagWindows)) { + if (getBoolArg(kPlatformWindows)) { print('\nBUILDING Windows for $packageName'); if (isWindowsPlugin(plugin)) { final int buildExitCode = await processRunner.runAndStream( flutterCommand, [ 'build', - kPlatformFlagWindows, + kPlatformWindows, if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart index 4788b9fa9143..1da6ef7a4c89 100644 --- a/script/tool/lib/src/common/core.dart +++ b/script/tool/lib/src/common/core.dart @@ -11,22 +11,22 @@ import 'package:yaml/yaml.dart'; typedef Print = void Function(Object? object); /// Key for windows platform. -const String kPlatformFlagWindows = 'windows'; +const String kPlatformWindows = 'windows'; /// Key for macos platform. -const String kPlatformFlagMacos = 'macos'; +const String kPlatformMacos = 'macos'; /// Key for linux platform. -const String kPlatformFlagLinux = 'linux'; +const String kPlatformLinux = 'linux'; /// Key for IPA (iOS) platform. -const String kPlatformFlagIos = 'ios'; +const String kPlatformIos = 'ios'; /// Key for APK (Android) platform. -const String kPlatformFlagAndroid = 'android'; +const String kPlatformAndroid = 'android'; /// Key for Web platform. -const String kPlatformFlagWeb = 'web'; +const String kPlatformWeb = 'web'; /// Key for enable experiment. const String kEnableExperiment = 'enable-experiment'; diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart index b6ac433db2e2..0277b78d566a 100644 --- a/script/tool/lib/src/common/plugin_utils.dart +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -29,12 +29,12 @@ enum PlatformSupport { /// implementation in order to return true. bool pluginSupportsPlatform(String platform, FileSystemEntity entity, {PlatformSupport? requiredMode}) { - assert(platform == kPlatformFlagIos || - platform == kPlatformFlagAndroid || - platform == kPlatformFlagWeb || - platform == kPlatformFlagMacos || - platform == kPlatformFlagWindows || - platform == kPlatformFlagLinux); + assert(platform == kPlatformIos || + platform == kPlatformAndroid || + platform == kPlatformWeb || + platform == kPlatformMacos || + platform == kPlatformWindows || + platform == kPlatformLinux); if (entity is! Directory) { return false; } @@ -59,7 +59,7 @@ bool pluginSupportsPlatform(String platform, FileSystemEntity entity, return false; } if (!pluginSection.containsKey('platforms')) { - return platform == kPlatformFlagIos || platform == kPlatformFlagAndroid; + return platform == kPlatformIos || platform == kPlatformAndroid; } return false; } @@ -81,30 +81,30 @@ bool pluginSupportsPlatform(String platform, FileSystemEntity entity, /// Returns whether the given directory contains a Flutter Android plugin. bool isAndroidPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagAndroid, entity); + return pluginSupportsPlatform(kPlatformAndroid, entity); } /// Returns whether the given directory contains a Flutter iOS plugin. bool isIosPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagIos, entity); + return pluginSupportsPlatform(kPlatformIos, entity); } /// Returns whether the given directory contains a Flutter web plugin. bool isWebPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagWeb, entity); + return pluginSupportsPlatform(kPlatformWeb, entity); } /// Returns whether the given directory contains a Flutter Windows plugin. bool isWindowsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagWindows, entity); + return pluginSupportsPlatform(kPlatformWindows, entity); } /// Returns whether the given directory contains a Flutter macOS plugin. bool isMacOsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagMacos, entity); + return pluginSupportsPlatform(kPlatformMacos, entity); } /// Returns whether the given directory contains a Flutter linux plugin. bool isLinuxPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformFlagLinux, entity); + return pluginSupportsPlatform(kPlatformLinux, entity); } diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index b6576cd13ba8..8a8cd6726d02 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -18,17 +18,17 @@ class DriveExamplesCommand extends PluginCommand { Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), }) : super(packagesDir, processRunner: processRunner) { - argParser.addFlag(kPlatformFlagAndroid, + argParser.addFlag(kPlatformAndroid, help: 'Runs the Android implementation of the examples'); - argParser.addFlag(kPlatformFlagIos, + argParser.addFlag(kPlatformIos, help: 'Runs the iOS implementation of the examples'); - argParser.addFlag(kPlatformFlagLinux, + argParser.addFlag(kPlatformLinux, help: 'Runs the Linux implementation of the examples'); - argParser.addFlag(kPlatformFlagMacos, + argParser.addFlag(kPlatformMacos, help: 'Runs the macOS implementation of the examples'); - argParser.addFlag(kPlatformFlagWeb, + argParser.addFlag(kPlatformWeb, help: 'Runs the web implementation of the examples'); - argParser.addFlag(kPlatformFlagWindows, + argParser.addFlag(kPlatformWindows, help: 'Runs the Windows implementation of the examples'); argParser.addOption( kEnableExperiment, @@ -55,10 +55,10 @@ class DriveExamplesCommand extends PluginCommand { Future run() async { final List failingTests = []; final List pluginsWithoutTests = []; - final bool isLinux = getBoolArg(kPlatformFlagLinux); - final bool isMacos = getBoolArg(kPlatformFlagMacos); - final bool isWeb = getBoolArg(kPlatformFlagWeb); - final bool isWindows = getBoolArg(kPlatformFlagWindows); + final bool isLinux = getBoolArg(kPlatformLinux); + final bool isMacos = getBoolArg(kPlatformMacos); + final bool isWeb = getBoolArg(kPlatformWeb); + final bool isWindows = getBoolArg(kPlatformWindows); await for (final Directory plugin in getPlugins()) { final String pluginName = plugin.basename; if (pluginName.endsWith('_platform_interface') && @@ -222,12 +222,12 @@ Tried searching for the following: Future _pluginSupportedOnCurrentPlatform( FileSystemEntity plugin) async { - final bool isAndroid = getBoolArg(kPlatformFlagAndroid); - final bool isIOS = getBoolArg(kPlatformFlagIos); - final bool isLinux = getBoolArg(kPlatformFlagLinux); - final bool isMacos = getBoolArg(kPlatformFlagMacos); - final bool isWeb = getBoolArg(kPlatformFlagWeb); - final bool isWindows = getBoolArg(kPlatformFlagWindows); + final bool isAndroid = getBoolArg(kPlatformAndroid); + final bool isIOS = getBoolArg(kPlatformIos); + final bool isLinux = getBoolArg(kPlatformLinux); + final bool isMacos = getBoolArg(kPlatformMacos); + final bool isWeb = getBoolArg(kPlatformWeb); + final bool isWindows = getBoolArg(kPlatformWindows); if (isAndroid) { return isAndroidPlugin(plugin); } diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 77e5659df3f6..741aa9d72837 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -37,8 +37,8 @@ class XCTestCommand extends PluginCommand { 'this is passed to the `-destination` argument in xcodebuild command.\n' 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.', ); - argParser.addFlag(kPlatformFlagIos, help: 'Runs the iOS tests'); - argParser.addFlag(kPlatformFlagMacos, help: 'Runs the macOS tests'); + argParser.addFlag(kPlatformIos, help: 'Runs the iOS tests'); + argParser.addFlag(kPlatformMacos, help: 'Runs the macOS tests'); } @override @@ -51,8 +51,8 @@ class XCTestCommand extends PluginCommand { @override Future run() async { - final bool testIos = getBoolArg(kPlatformFlagIos); - final bool testMacos = getBoolArg(kPlatformFlagMacos); + final bool testIos = getBoolArg(kPlatformIos); + final bool testMacos = getBoolArg(kPlatformMacos); if (!(testIos || testMacos)) { print('At least one platform flag must be provided.'); diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 1ef4fdc44b42..84e3478f78c3 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -53,8 +53,7 @@ void main() { }); test('skips flutter pub get for examples', () async { - final Directory plugin1Dir = - createFakePlugin('a', packagesDir, withSingleExample: true); + final Directory plugin1Dir = createFakePlugin('a', packagesDir); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -121,7 +120,7 @@ void main() { group('verifies analysis settings', () { test('fails analysis_options.yaml', () async { - createFakePlugin('foo', packagesDir, withExtraFiles: >[ + createFakePlugin('foo', packagesDir, extraFiles: >[ ['analysis_options.yaml'] ]); @@ -130,7 +129,7 @@ void main() { }); test('fails .analysis_options', () async { - createFakePlugin('foo', packagesDir, withExtraFiles: >[ + createFakePlugin('foo', packagesDir, extraFiles: >[ ['.analysis_options'] ]); @@ -140,7 +139,7 @@ void main() { test('takes an allow list', () async { final Directory pluginDir = - createFakePlugin('foo', packagesDir, withExtraFiles: >[ + createFakePlugin('foo', packagesDir, extraFiles: >[ ['analysis_options.yaml'] ]); @@ -161,7 +160,7 @@ void main() { // See: https://github.com/flutter/flutter/issues/78994 test('takes an empty allow list', () async { - createFakePlugin('foo', packagesDir, withExtraFiles: >[ + createFakePlugin('foo', packagesDir, extraFiles: >[ ['analysis_options.yaml'] ]); diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 2ad17b374ba7..d3c51cfc4e3a 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -6,6 +6,8 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/build_examples_command.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:test/test.dart'; @@ -35,17 +37,14 @@ void main() { test('building for iOS when plugin is not set up for iOS results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isLinuxPlugin: false); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--ipa', '--no-macos']); final String packageName = @@ -67,17 +66,16 @@ void main() { }); test('building for ios', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'build-examples', '--ipa', @@ -114,17 +112,14 @@ void main() { test( 'building for Linux when plugin is not set up for Linux results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isLinuxPlugin: false); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--linux']); final String packageName = @@ -146,17 +141,16 @@ void main() { }); test('building for Linux', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isLinuxPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformLinux: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--linux']); final String packageName = @@ -181,16 +175,14 @@ void main() { test('building for macos with no implementation results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ]); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--macos']); final String packageName = @@ -212,18 +204,17 @@ void main() { }); test('building for macos', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ['example', 'macos', 'macos.swift'], - ], - isMacOsPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ['example', 'macos', 'macos.swift'], + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--macos']); final String packageName = @@ -247,16 +238,14 @@ void main() { }); test('building for web with no implementation results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ]); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--web']); final String packageName = @@ -278,18 +267,17 @@ void main() { }); test('building for web', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ['example', 'web', 'index.html'], - ], - isWebPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ['example', 'web', 'index.html'], + ], platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--web']); final String packageName = @@ -315,17 +303,14 @@ void main() { test( 'building for Windows when plugin is not set up for Windows results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isWindowsPlugin: false); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--windows']); final String packageName = @@ -347,17 +332,16 @@ void main() { }); test('building for windows', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isWindowsPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformWindows: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--no-ipa', '--windows']); final String packageName = @@ -383,17 +367,14 @@ void main() { test( 'building for Android when plugin is not set up for Android results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isLinuxPlugin: false); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ]); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint( runner, ['build-examples', '--apk', '--no-ipa']); final String packageName = @@ -415,17 +396,16 @@ void main() { }); test('building for android', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isAndroidPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'build-examples', '--apk', @@ -453,17 +433,16 @@ void main() { }); test('enable-experiment flag for Android', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isAndroidPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - await runCapturingPrint(runner, [ 'build-examples', '--apk', @@ -483,17 +462,16 @@ void main() { }); test('enable-experiment flag for ios', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - await runCapturingPrint(runner, [ 'build-examples', '--ipa', diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 58d202e19920..deb8e4f56e2c 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -95,8 +95,7 @@ void main() { }); test('exclude federated plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'federated'); + createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run([ 'sample', @@ -108,8 +107,7 @@ void main() { test('exclude entire federated plugins when plugins flag is specified', () async { - createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'federated'); + createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runner.run([ 'sample', @@ -303,8 +301,8 @@ packages/plugin1/plugin1/plugin1.dart packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart packages/plugin1/plugin1_web/plugin1_web.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'plugin1'); + final Directory plugin1 = + createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); await runner.run([ @@ -323,8 +321,8 @@ packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'plugin1'); + final Directory plugin1 = + createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); await runner.run([ @@ -343,8 +341,8 @@ packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart '''; - final Directory plugin1 = createFakePlugin('plugin1', packagesDir, - parentDirectoryName: 'plugin1'); + final Directory plugin1 = + createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); await runner.run([ diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart index aaa850155da4..c32c3f8e02bf 100644 --- a/script/tool/test/common/plugin_utils_test.dart +++ b/script/tool/test/common/plugin_utils_test.dart @@ -4,6 +4,7 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:test/test.dart'; @@ -22,113 +23,112 @@ void main() { test('no platforms', () async { final Directory plugin = createFakePlugin('plugin', packagesDir); - expect(pluginSupportsPlatform('android', plugin), isFalse); - expect(pluginSupportsPlatform('ios', plugin), isFalse); - expect(pluginSupportsPlatform('linux', plugin), isFalse); - expect(pluginSupportsPlatform('macos', plugin), isFalse); - expect(pluginSupportsPlatform('web', plugin), isFalse); - expect(pluginSupportsPlatform('windows', plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformIos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformLinux, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformMacos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformWeb, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformWindows, plugin), isFalse); }); test('all platforms', () async { - final Directory plugin = createFakePlugin( - 'plugin', - packagesDir, - isAndroidPlugin: true, - isIosPlugin: true, - isLinuxPlugin: true, - isMacOsPlugin: true, - isWebPlugin: true, - isWindowsPlugin: true, - ); + final Directory plugin = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + kPlatformLinux: PlatformSupport.inline, + kPlatformMacos: PlatformSupport.inline, + kPlatformWeb: PlatformSupport.inline, + kPlatformWindows: PlatformSupport.inline, + }); - expect(pluginSupportsPlatform('android', plugin), isTrue); - expect(pluginSupportsPlatform('ios', plugin), isTrue); - expect(pluginSupportsPlatform('linux', plugin), isTrue); - expect(pluginSupportsPlatform('macos', plugin), isTrue); - expect(pluginSupportsPlatform('web', plugin), isTrue); - expect(pluginSupportsPlatform('windows', plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformIos, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformLinux, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformMacos, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformWeb, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformWindows, plugin), isTrue); }); test('some platforms', () async { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - isAndroidPlugin: true, - isIosPlugin: false, - isLinuxPlugin: true, - isMacOsPlugin: false, - isWebPlugin: true, - isWindowsPlugin: false, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformLinux: PlatformSupport.inline, + kPlatformWeb: PlatformSupport.inline, + }, ); - expect(pluginSupportsPlatform('android', plugin), isTrue); - expect(pluginSupportsPlatform('ios', plugin), isFalse); - expect(pluginSupportsPlatform('linux', plugin), isTrue); - expect(pluginSupportsPlatform('macos', plugin), isFalse); - expect(pluginSupportsPlatform('web', plugin), isTrue); - expect(pluginSupportsPlatform('windows', plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformIos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformLinux, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformMacos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformWeb, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformWindows, plugin), isFalse); }); test('inline plugins are only detected as inline', () async { - // createFakePlugin makes non-federated pubspec entries. final Directory plugin = createFakePlugin( 'plugin', packagesDir, - isAndroidPlugin: true, - isIosPlugin: true, - isLinuxPlugin: true, - isMacOsPlugin: true, - isWebPlugin: true, - isWindowsPlugin: true, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + kPlatformLinux: PlatformSupport.inline, + kPlatformMacos: PlatformSupport.inline, + kPlatformWeb: PlatformSupport.inline, + kPlatformWindows: PlatformSupport.inline, + }, ); expect( - pluginSupportsPlatform('android', plugin, + pluginSupportsPlatform(kPlatformAndroid, plugin, requiredMode: PlatformSupport.inline), isTrue); expect( - pluginSupportsPlatform('android', plugin, + pluginSupportsPlatform(kPlatformAndroid, plugin, requiredMode: PlatformSupport.federated), isFalse); expect( - pluginSupportsPlatform('ios', plugin, + pluginSupportsPlatform(kPlatformIos, plugin, requiredMode: PlatformSupport.inline), isTrue); expect( - pluginSupportsPlatform('ios', plugin, + pluginSupportsPlatform(kPlatformIos, plugin, requiredMode: PlatformSupport.federated), isFalse); expect( - pluginSupportsPlatform('linux', plugin, + pluginSupportsPlatform(kPlatformLinux, plugin, requiredMode: PlatformSupport.inline), isTrue); expect( - pluginSupportsPlatform('linux', plugin, + pluginSupportsPlatform(kPlatformLinux, plugin, requiredMode: PlatformSupport.federated), isFalse); expect( - pluginSupportsPlatform('macos', plugin, + pluginSupportsPlatform(kPlatformMacos, plugin, requiredMode: PlatformSupport.inline), isTrue); expect( - pluginSupportsPlatform('macos', plugin, + pluginSupportsPlatform(kPlatformMacos, plugin, requiredMode: PlatformSupport.federated), isFalse); expect( - pluginSupportsPlatform('web', plugin, + pluginSupportsPlatform(kPlatformWeb, plugin, requiredMode: PlatformSupport.inline), isTrue); expect( - pluginSupportsPlatform('web', plugin, + pluginSupportsPlatform(kPlatformWeb, plugin, requiredMode: PlatformSupport.federated), isFalse); expect( - pluginSupportsPlatform('windows', plugin, + pluginSupportsPlatform(kPlatformWindows, plugin, requiredMode: PlatformSupport.inline), isTrue); expect( - pluginSupportsPlatform('windows', plugin, + pluginSupportsPlatform(kPlatformWindows, plugin, requiredMode: PlatformSupport.federated), isFalse); }); @@ -138,71 +138,62 @@ void main() { final Directory plugin = createFakePlugin( pluginName, packagesDir, - isAndroidPlugin: true, - isIosPlugin: true, - isLinuxPlugin: true, - isMacOsPlugin: true, - isWebPlugin: true, - isWindowsPlugin: true, - ); - - createFakePubspec( - plugin, - name: pluginName, - androidSupport: PlatformSupport.federated, - iosSupport: PlatformSupport.federated, - linuxSupport: PlatformSupport.federated, - macosSupport: PlatformSupport.federated, - webSupport: PlatformSupport.federated, - windowsSupport: PlatformSupport.federated, + platformSupport: { + kPlatformAndroid: PlatformSupport.federated, + kPlatformIos: PlatformSupport.federated, + kPlatformLinux: PlatformSupport.federated, + kPlatformMacos: PlatformSupport.federated, + kPlatformWeb: PlatformSupport.federated, + kPlatformWindows: PlatformSupport.federated, + }, ); expect( - pluginSupportsPlatform('android', plugin, + pluginSupportsPlatform(kPlatformAndroid, plugin, requiredMode: PlatformSupport.federated), isTrue); expect( - pluginSupportsPlatform('android', plugin, + pluginSupportsPlatform(kPlatformAndroid, plugin, requiredMode: PlatformSupport.inline), isFalse); expect( - pluginSupportsPlatform('ios', plugin, + pluginSupportsPlatform(kPlatformIos, plugin, requiredMode: PlatformSupport.federated), isTrue); expect( - pluginSupportsPlatform('ios', plugin, + pluginSupportsPlatform(kPlatformIos, plugin, requiredMode: PlatformSupport.inline), isFalse); expect( - pluginSupportsPlatform('linux', plugin, + pluginSupportsPlatform(kPlatformLinux, plugin, requiredMode: PlatformSupport.federated), isTrue); expect( - pluginSupportsPlatform('linux', plugin, + pluginSupportsPlatform(kPlatformLinux, plugin, requiredMode: PlatformSupport.inline), isFalse); expect( - pluginSupportsPlatform('macos', plugin, + pluginSupportsPlatform(kPlatformMacos, plugin, requiredMode: PlatformSupport.federated), isTrue); expect( - pluginSupportsPlatform('macos', plugin, + pluginSupportsPlatform(kPlatformMacos, plugin, requiredMode: PlatformSupport.inline), isFalse); expect( - pluginSupportsPlatform('web', plugin, + pluginSupportsPlatform(kPlatformWeb, plugin, requiredMode: PlatformSupport.federated), isTrue); expect( - pluginSupportsPlatform('web', plugin, + pluginSupportsPlatform(kPlatformWeb, plugin, requiredMode: PlatformSupport.inline), isFalse); expect( - pluginSupportsPlatform('windows', plugin, + pluginSupportsPlatform(kPlatformWindows, plugin, requiredMode: PlatformSupport.federated), isTrue); expect( - pluginSupportsPlatform('windows', plugin, + pluginSupportsPlatform(kPlatformWindows, plugin, requiredMode: PlatformSupport.inline), isFalse); }); diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 9c5bd18cfb11..8893110d1257 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -6,6 +6,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/drive_examples_command.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; @@ -35,19 +36,18 @@ void main() { }); test('driving under folder "test"', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test', 'plugin.dart'], - ], - isIosPlugin: true, - isAndroidPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test', 'plugin.dart'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', ]); @@ -80,19 +80,18 @@ void main() { }); test('driving under folder "test_driver"', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isAndroidPlugin: true, - isIosPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', ]); @@ -126,17 +125,12 @@ void main() { test('driving under folder "test_driver" when test files are missing"', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ], - isAndroidPlugin: true, - isIosPlugin: true); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }); await expectLater( () => runCapturingPrint(runner, ['drive-examples']), @@ -145,17 +139,12 @@ void main() { test('a plugin without any integration test files is reported as an error', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'lib', 'main.dart'], - ], - isAndroidPlugin: true, - isIosPlugin: true); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'lib', 'main.dart'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }); await expectLater( () => runCapturingPrint(runner, ['drive-examples']), @@ -165,21 +154,20 @@ void main() { test( 'driving under folder "test_driver" when targets are under "integration_test"', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'integration_test.dart'], - ['example', 'integration_test', 'bar_test.dart'], - ['example', 'integration_test', 'foo_test.dart'], - ['example', 'integration_test', 'ignore_me.dart'], - ], - isAndroidPlugin: true, - isIosPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'integration_test.dart'], + ['example', 'integration_test', 'bar_test.dart'], + ['example', 'integration_test', 'foo_test.dart'], + ['example', 'integration_test', 'ignore_me.dart'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', ]); @@ -222,17 +210,10 @@ void main() { }); test('driving when plugin does not support Linux is a no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isMacOsPlugin: false); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ]); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -255,18 +236,17 @@ void main() { }); test('driving on a Linux plugin', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isLinuxPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ], platformSupport: { + kPlatformLinux: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', '--linux', @@ -302,16 +282,10 @@ void main() { }); test('driving when plugin does not suppport macOS is a no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ]); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ]); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -333,19 +307,18 @@ void main() { expect(processRunner.recordedCalls, []); }); test('driving on a macOS plugin', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ['example', 'macos', 'macos.swift'], - ], - isMacOsPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ['example', 'macos', 'macos.swift'], + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', '--macos', @@ -381,17 +354,10 @@ void main() { }); test('driving when plugin does not suppport web is a no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isWebPlugin: false); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ]); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -414,18 +380,17 @@ void main() { }); test('driving a web plugin', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isWebPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ], platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', '--web', @@ -463,17 +428,10 @@ void main() { }); test('driving when plugin does not suppport Windows is a no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isWindowsPlugin: false); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ]); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -496,18 +454,17 @@ void main() { }); test('driving on a Windows plugin', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isWindowsPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ], platformSupport: { + kPlatformWindows: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final List output = await runCapturingPrint(runner, [ 'drive-examples', '--windows', @@ -543,17 +500,12 @@ void main() { }); test('driving when plugin does not support mobile is no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], - isMacOsPlugin: true); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - createFakePubspec(pluginExampleDirectory, isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test_driver', 'plugin.dart'], + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -575,7 +527,8 @@ void main() { }); test('platform interface plugins are silently skipped', () async { - createFakePlugin('aplugin_platform_interface', packagesDir); + createFakePlugin('aplugin_platform_interface', packagesDir, + examples: []); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -595,19 +548,18 @@ void main() { }); test('enable-experiment flag', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test', 'plugin.dart'], - ], - isIosPlugin: true, - isAndroidPlugin: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test_driver', 'plugin_test.dart'], + ['example', 'test', 'plugin.dart'], + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - await runCapturingPrint(runner, [ 'drive-examples', '--enable-experiment=exp1', diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart index 0bc8f1e197c6..8c39b8cf70e8 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_test.dart @@ -40,7 +40,7 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(1); processRunner.processToReturn = mockProcess; - createFakePlugin('plugin', packagesDir, withExtraFiles: >[ + createFakePlugin('plugin', packagesDir, extraFiles: >[ ['lib/test/should_not_run_e2e.dart'], ['example', 'test_driver', 'plugin_e2e.dart'], ['example', 'test_driver', 'plugin_e2e_test.dart'], @@ -65,7 +65,7 @@ void main() { }); test('runs e2e tests', () async { - createFakePlugin('plugin', packagesDir, withExtraFiles: >[ + createFakePlugin('plugin', packagesDir, extraFiles: >[ ['test', 'plugin_test.dart'], ['test', 'plugin_e2e.dart'], ['should_not_run_e2e.dart'], @@ -168,7 +168,7 @@ void main() { }); test('experimental flag', () async { - createFakePlugin('plugin', packagesDir, withExtraFiles: >[ + createFakePlugin('plugin', packagesDir, extraFiles: >[ ['test', 'plugin_test.dart'], ['test', 'plugin_e2e.dart'], ['should_not_run_e2e.dart'], diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index a1c2d3b864c4..3c6319ed447f 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -5,6 +5,8 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/java_test_command.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -34,10 +36,10 @@ void main() { final Directory plugin = createFakePlugin( 'plugin1', packagesDir, - isAndroidPlugin: true, - isFlutter: true, - withSingleExample: true, - withExtraFiles: >[ + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: >[ ['example/android', 'gradlew'], ['android/src/test', 'example_test.java'], ], @@ -61,10 +63,10 @@ void main() { final Directory plugin = createFakePlugin( 'plugin1', packagesDir, - isAndroidPlugin: true, - isFlutter: true, - withSingleExample: true, - withExtraFiles: >[ + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: >[ ['example/android', 'gradlew'], ['example/android/app/src/test', 'example_test.java'], ], diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 0183704f72c3..45dc92e69940 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -45,7 +45,7 @@ void main() { }); test('only runs on macOS', () async { - createFakePlugin('plugin1', packagesDir, withExtraFiles: >[ + createFakePlugin('plugin1', packagesDir, extraFiles: >[ ['plugin1.podspec'], ]); @@ -59,11 +59,11 @@ void main() { }); test('runs pod lib lint on a podspec', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, - withExtraFiles: >[ - ['ios', 'plugin1.podspec'], - ['bogus.dart'], // Ignore non-podspecs. - ]); + final Directory plugin1Dir = + createFakePlugin('plugin1', packagesDir, extraFiles: >[ + ['ios', 'plugin1.podspec'], + ['bogus.dart'], // Ignore non-podspecs. + ]); processRunner.resultStdout = 'Foo'; processRunner.resultStderr = 'Bar'; @@ -106,10 +106,10 @@ void main() { }); test('skips podspecs with known issues', () async { - createFakePlugin('plugin1', packagesDir, withExtraFiles: >[ + createFakePlugin('plugin1', packagesDir, extraFiles: >[ ['plugin1.podspec'] ]); - createFakePlugin('plugin2', packagesDir, withExtraFiles: >[ + createFakePlugin('plugin2', packagesDir, extraFiles: >[ ['plugin2.podspec'] ]); @@ -125,10 +125,10 @@ void main() { }); test('allow warnings for podspecs with known warnings', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, - withExtraFiles: >[ - ['plugin1.podspec'], - ]); + final Directory plugin1Dir = + createFakePlugin('plugin1', packagesDir, extraFiles: >[ + ['plugin1.podspec'], + ]); await runner.run(['podspecs', '--ignore-warnings=plugin1']); diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart index 02b898c5c3fc..22f00ea046c1 100644 --- a/script/tool/test/list_command_test.dart +++ b/script/tool/test/list_command_test.dart @@ -42,10 +42,10 @@ void main() { }); test('lists examples', () async { - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir, - withExamples: ['example1', 'example2']); - createFakePlugin('plugin3', packagesDir); + examples: ['example1', 'example2']); + createFakePlugin('plugin3', packagesDir, examples: []); final List examples = await runCapturingPrint(runner, ['list', '--type=example']); @@ -61,10 +61,10 @@ void main() { }); test('lists packages', () async { - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir, - withExamples: ['example1', 'example2']); - createFakePlugin('plugin3', packagesDir); + examples: ['example1', 'example2']); + createFakePlugin('plugin3', packagesDir, examples: []); final List packages = await runCapturingPrint(runner, ['list', '--type=package']); @@ -83,10 +83,10 @@ void main() { }); test('lists files', () async { - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir, - withExamples: ['example1', 'example2']); - createFakePlugin('plugin3', packagesDir); + examples: ['example1', 'example2']); + createFakePlugin('plugin3', packagesDir, examples: []); final List examples = await runCapturingPrint(runner, ['list', '--type=file']); @@ -95,11 +95,14 @@ void main() { examples, unorderedEquals([ '/packages/plugin1/pubspec.yaml', + '/packages/plugin1/CHANGELOG.md', '/packages/plugin1/example/pubspec.yaml', '/packages/plugin2/pubspec.yaml', + '/packages/plugin2/CHANGELOG.md', '/packages/plugin2/example/example1/pubspec.yaml', '/packages/plugin2/example/example2/pubspec.yaml', '/packages/plugin3/pubspec.yaml', + '/packages/plugin3/CHANGELOG.md', ]), ); }); diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index c0ccd2989cf9..26938cc92791 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -40,8 +40,10 @@ void main() { }); test('publish check all packages', () async { - final Directory plugin1Dir = createFakePlugin('a', packagesDir); - final Directory plugin2Dir = createFakePlugin('b', packagesDir); + final Directory plugin1Dir = + createFakePlugin('plugin_tools_test_package_a', packagesDir); + final Directory plugin2Dir = + createFakePlugin('plugin_tools_test_package_b', packagesDir); processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), @@ -66,7 +68,7 @@ void main() { }); test('fail on negative test', () async { - createFakePlugin('a', packagesDir); + createFakePlugin('plugin_tools_test_package_a', packagesDir); final MockProcess process = MockProcess(); process.stdoutController.close(); // ignore: unawaited_futures @@ -186,13 +188,8 @@ void main() { ); runner.addCommand(command); - final Directory plugin1Dir = - createFakePlugin('no_publish_a', packagesDir, includeVersion: true); - final Directory plugin2Dir = - createFakePlugin('no_publish_b', packagesDir, includeVersion: true); - - createFakePubspec(plugin1Dir, name: 'no_publish_a', version: '0.1.0'); - createFakePubspec(plugin2Dir, name: 'no_publish_b', version: '0.2.0'); + createFakePlugin('no_publish_a', packagesDir, version: '0.1.0'); + createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), @@ -250,13 +247,8 @@ void main() { ); runner.addCommand(command); - final Directory plugin1Dir = - createFakePlugin('no_publish_a', packagesDir, includeVersion: true); - final Directory plugin2Dir = - createFakePlugin('no_publish_b', packagesDir, includeVersion: true); - - createFakePubspec(plugin1Dir, name: 'no_publish_a', version: '0.1.0'); - createFakePubspec(plugin2Dir, name: 'no_publish_b', version: '0.2.0'); + createFakePlugin('no_publish_a', packagesDir, version: '0.1.0'); + createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), @@ -318,12 +310,9 @@ void main() { runner.addCommand(command); final Directory plugin1Dir = - createFakePlugin('no_publish_a', packagesDir, includeVersion: true); - final Directory plugin2Dir = - createFakePlugin('no_publish_b', packagesDir, includeVersion: true); + createFakePlugin('no_publish_a', packagesDir, version: '0.1.0'); + createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); - createFakePubspec(plugin1Dir, name: 'no_publish_a', version: '0.1.0'); - createFakePubspec(plugin2Dir, name: 'no_publish_b', version: '0.2.0'); await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); processRunner.processesToReturn.add( diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index ef682bfe61f6..a2ea9816ea0c 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -50,9 +50,8 @@ void main() { testRoot = fileSystem.directory(testRoot.resolveSymbolicLinksSync()); packagesDir = createPackagesDirectory(parentDir: testRoot); pluginDir = - createFakePlugin(testPluginName, packagesDir, withSingleExample: false); + createFakePlugin(testPluginName, packagesDir, examples: []); assert(pluginDir != null && pluginDir.existsSync()); - createFakePubspec(pluginDir, version: '0.0.1'); io.Process.runSync('git', ['init'], workingDirectory: testRoot.path); gitDir = await GitDir.fromExisting(testRoot.path); @@ -138,7 +137,8 @@ void main() { }); test('can publish non-flutter package', () async { - createFakePubspec(pluginDir, version: '0.0.1', isFlutter: false); + const String packageName = 'a_package'; + createFakePackage(packageName, packagesDir); io.Process.runSync('git', ['init'], workingDirectory: testRoot.path); gitDir = await GitDir.fromExisting(testRoot.path); @@ -149,7 +149,7 @@ void main() { await commandRunner.run([ 'publish-plugin', '--package', - testPluginName, + packageName, '--no-push-tags', '--no-tag-release' ]); @@ -284,9 +284,9 @@ void main() { '--no-push-tags', ]); - final String? tag = - (await gitDir.runCommand(['show-ref', 'fake_package-v0.0.1'])) - .stdout as String?; + final String? tag = (await gitDir + .runCommand(['show-ref', '$testPluginName-v0.0.1'])) + .stdout as String?; expect(tag, isNotEmpty); }); @@ -303,7 +303,7 @@ void main() { expect(printedMessages, contains('Publish foo failed.')); final String? tag = (await gitDir.runCommand( - ['show-ref', 'fake_package-v0.0.1'], + ['show-ref', '$testPluginName-v0.0.1'], throwOnError: false)) .stdout as String?; expect(tag, isEmpty); @@ -342,7 +342,7 @@ void main() { expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1'); + expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); expect(printedMessages.last, 'Done!'); }); @@ -360,7 +360,7 @@ void main() { expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1'); + expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); expect(printedMessages.last, 'Done!'); }); @@ -378,7 +378,7 @@ void main() { containsAllInOrder([ '=============== DRY RUN ===============', 'Running `pub publish ` in ${pluginDir.path}...\n', - 'Tagging release fake_package-v0.0.1...', + 'Tagging release $testPluginName-v0.0.1...', 'Pushing tag to upstream...', 'Done!' ])); @@ -399,7 +399,7 @@ void main() { expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'origin'); - expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1'); + expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); expect(printedMessages.last, 'Done!'); }); @@ -428,15 +428,12 @@ void main() { test('can release newly created plugins', () async { // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.1'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.1'); + final Directory pluginDir2 = createFakePlugin( + 'plugin2', + packagesDir.childDirectory('plugin2'), + ); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -467,10 +464,7 @@ void main() { test('can release newly created plugins, while there are existing plugins', () async { // Prepare an exiting plugin and tag it - final Directory pluginDir0 = - createFakePlugin('plugin0', packagesDir, withSingleExample: true); - createFakePubspec(pluginDir0, - name: 'plugin0', isFlutter: false, version: '0.0.1'); + createFakePlugin('plugin0', packagesDir); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -481,15 +475,10 @@ void main() { processRunner.pushTagsArgs.clear(); // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.1'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.1'); + final Directory pluginDir2 = + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -517,15 +506,10 @@ void main() { test('can release newly created plugins, dry run', () async { // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.1'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.1'); + final Directory pluginDir2 = + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. @@ -558,15 +542,10 @@ void main() { test('version change triggers releases.', () async { // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.1'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.1'); + final Directory pluginDir2 = + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -641,15 +620,10 @@ void main() { 'delete package will not trigger publish but exit the command successfully.', () async { // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.1'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.1'); + final Directory pluginDir2 = + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -722,14 +696,11 @@ void main() { () async { // Non-federated final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.2'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.2'); + final Directory pluginDir2 = createFakePlugin( + 'plugin2', packagesDir.childDirectory('plugin2'), + version: '0.0.2'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); // Immediately return 0 when running `pub publish`. @@ -795,15 +766,10 @@ void main() { test('No version change does not release any plugins', () async { // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, withSingleExample: true); + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated - final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir, - withSingleExample: true, parentDirectoryName: 'plugin2'); - createFakePubspec(pluginDir1, - name: 'plugin1', isFlutter: false, version: '0.0.1'); - createFakePubspec(pluginDir2, - name: 'plugin2', isFlutter: false, version: '0.0.1'); + final Directory pluginDir2 = + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); io.Process.runSync('git', ['init'], workingDirectory: testRoot.path); diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index f5fe6aef849a..af27ac5bd2fe 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -88,8 +88,7 @@ dev_dependencies: } test('passes for a plugin following conventions', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -114,8 +113,7 @@ ${devDependenciesSection()} }); test('passes for a Flutter package following conventions', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin')} @@ -163,8 +161,7 @@ ${dependenciesSection()} }); test('fails when homepage is included', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeHomepage: true)} @@ -184,8 +181,7 @@ ${devDependenciesSection()} }); test('fails when repository is missing', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeRepository: false)} @@ -205,8 +201,7 @@ ${devDependenciesSection()} }); test('fails when homepage is given instead of repository', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeHomepage: true, includeRepository: false)} @@ -226,8 +221,7 @@ ${devDependenciesSection()} }); test('fails when issue tracker is missing', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true, includeIssueTracker: false)} @@ -247,8 +241,7 @@ ${devDependenciesSection()} }); test('fails when environment section is out of order', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -268,8 +261,7 @@ ${environmentSection()} }); test('fails when flutter section is out of order', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -289,8 +281,7 @@ ${devDependenciesSection()} }); test('fails when dependencies section is out of order', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} @@ -310,8 +301,7 @@ ${dependenciesSection()} }); test('fails when devDependencies section is out of order', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, withSingleExample: true); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' ${headerSection('plugin', isPlugin: true)} diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 5cbbdf5b8d46..b2f663f1e904 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -5,6 +5,8 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/test_command.dart'; import 'package:test/test.dart'; @@ -29,14 +31,14 @@ void main() { }); test('runs flutter test on each plugin', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory plugin1Dir = + createFakePlugin('plugin1', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); + final Directory plugin2Dir = + createFakePlugin('plugin2', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); await runner.run(['test']); @@ -53,10 +55,10 @@ void main() { test('skips testing plugins without test directory', () async { createFakePlugin('plugin1', packagesDir); - final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory plugin2Dir = + createFakePlugin('plugin2', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); await runner.run(['test']); @@ -70,16 +72,14 @@ void main() { }); test('runs pub run test on non-Flutter packages', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, - isFlutter: true, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, - isFlutter: false, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory pluginDir = + createFakePlugin('a', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); + final Directory packageDir = + createFakePackage('b', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); await runner.run(['test', '--enable-experiment=exp1']); @@ -89,12 +89,12 @@ void main() { ProcessCall( 'flutter', const ['test', '--color', '--enable-experiment=exp1'], - plugin1Dir.path), - ProcessCall('dart', const ['pub', 'get'], plugin2Dir.path), + pluginDir.path), + ProcessCall('dart', const ['pub', 'get'], packageDir.path), ProcessCall( 'dart', const ['pub', 'run', '--enable-experiment=exp1', 'test'], - plugin2Dir.path), + packageDir.path), ]), ); }); @@ -103,11 +103,12 @@ void main() { final Directory pluginDir = createFakePlugin( 'plugin', packagesDir, - withExtraFiles: >[ + extraFiles: >[ ['test', 'empty_test.dart'], ], - isFlutter: true, - isWebPlugin: true, + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }, ); await runner.run(['test']); @@ -124,16 +125,14 @@ void main() { }); test('enable-experiment flag', () async { - final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, - isFlutter: true, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, - isFlutter: false, - withExtraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory pluginDir = + createFakePlugin('a', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); + final Directory packageDir = + createFakePackage('b', packagesDir, extraFiles: >[ + ['test', 'empty_test.dart'], + ]); await runner.run(['test', '--enable-experiment=exp1']); @@ -143,12 +142,12 @@ void main() { ProcessCall( 'flutter', const ['test', '--color', '--enable-experiment=exp1'], - plugin1Dir.path), - ProcessCall('dart', const ['pub', 'get'], plugin2Dir.path), + pluginDir.path), + ProcessCall('dart', const ['pub', 'get'], packageDir.path), ProcessCall( 'dart', const ['pub', 'run', '--enable-experiment=exp1', 'test'], - plugin2Dir.path), + packageDir.path), ]), ); }); diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 79c46fcc50e5..4ced4eb48379 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -9,6 +9,7 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:meta/meta.dart'; @@ -32,61 +33,70 @@ Directory createPackagesDirectory( } /// Creates a plugin package with the given [name] in [packagesDirectory]. +/// +/// [platformSupport] is a map of platform string to the support details for +/// that platform. Directory createFakePlugin( String name, - Directory packagesDirectory, { - bool withSingleExample = false, - List withExamples = const [], - List> withExtraFiles = const >[], - bool isFlutter = true, - // TODO(stuartmorgan): Change these platform switches to support type enums. - bool isAndroidPlugin = false, - bool isIosPlugin = false, - bool isWebPlugin = false, - bool isLinuxPlugin = false, - bool isMacOsPlugin = false, - bool isWindowsPlugin = false, - bool includeChangeLog = false, - bool includeVersion = false, - String version = '0.0.1', - String parentDirectoryName = '', + Directory parentDirectory, { + List examples = const ['example'], + List> extraFiles = const >[], + Map platformSupport = + const {}, + String? version = '0.0.1', }) { - assert(!(withSingleExample && withExamples.isNotEmpty), - 'cannot pass withSingleExample and withExamples simultaneously'); + final Directory pluginDirectory = createFakePackage(name, parentDirectory, + isFlutter: true, + examples: examples, + extraFiles: extraFiles, + version: version); + + createFakePubspec( + pluginDirectory, + name: name, + isFlutter: true, + isPlugin: true, + platformSupport: platformSupport, + version: version, + ); - Directory parentDirectory = packagesDirectory; - if (parentDirectoryName != '') { - parentDirectory = parentDirectory.childDirectory(parentDirectoryName); + final FileSystem fileSystem = pluginDirectory.fileSystem; + for (final List file in extraFiles) { + final List newFilePath = [pluginDirectory.path, ...file]; + final File newFile = fileSystem.file(fileSystem.path.joinAll(newFilePath)); + newFile.createSync(recursive: true); } - final Directory pluginDirectory = parentDirectory.childDirectory(name); - pluginDirectory.createSync(recursive: true); - - createFakePubspec(pluginDirectory, - name: name, - isFlutter: isFlutter, - androidSupport: isAndroidPlugin ? PlatformSupport.inline : null, - iosSupport: isIosPlugin ? PlatformSupport.inline : null, - webSupport: isWebPlugin ? PlatformSupport.inline : null, - linuxSupport: isLinuxPlugin ? PlatformSupport.inline : null, - macosSupport: isMacOsPlugin ? PlatformSupport.inline : null, - windowsSupport: isWindowsPlugin ? PlatformSupport.inline : null, - version: includeVersion ? version : null); - if (includeChangeLog) { - createFakeCHANGELOG(pluginDirectory, ''' -## 0.0.1 + + return pluginDirectory; +} + +/// Creates a plugin package with the given [name] in [packagesDirectory]. +Directory createFakePackage( + String name, + Directory parentDirectory, { + List examples = const ['example'], + List> extraFiles = const >[], + bool isFlutter = false, + String? version = '0.0.1', +}) { + final Directory packageDirectory = parentDirectory.childDirectory(name); + packageDirectory.createSync(recursive: true); + + createFakePubspec(packageDirectory, name: name, isFlutter: isFlutter); + createFakeCHANGELOG(packageDirectory, ''' +## $version * Some changes. '''); - } - if (withSingleExample) { - final Directory exampleDir = pluginDirectory.childDirectory('example') + if (examples.length == 1) { + final Directory exampleDir = packageDirectory.childDirectory(examples.first) ..createSync(); createFakePubspec(exampleDir, name: '${name}_example', isFlutter: isFlutter, publishTo: 'none'); - } else if (withExamples.isNotEmpty) { - final Directory exampleDir = pluginDirectory.childDirectory('example') + } else if (examples.isNotEmpty) { + final Directory exampleDir = packageDirectory.childDirectory('example') ..createSync(); - for (final String example in withExamples) { + for (final String example in examples) { final Directory currentExample = exampleDir.childDirectory(example) ..createSync(); createFakePubspec(currentExample, @@ -94,14 +104,14 @@ Directory createFakePlugin( } } - final FileSystem fileSystem = pluginDirectory.fileSystem; - for (final List file in withExtraFiles) { - final List newFilePath = [pluginDirectory.path, ...file]; + final FileSystem fileSystem = packageDirectory.fileSystem; + for (final List file in extraFiles) { + final List newFilePath = [packageDirectory.path, ...file]; final File newFile = fileSystem.file(fileSystem.path.joinAll(newFilePath)); newFile.createSync(recursive: true); } - return pluginDirectory; + return packageDirectory; } void createFakeCHANGELOG(Directory parent, String texts) { @@ -110,45 +120,38 @@ void createFakeCHANGELOG(Directory parent, String texts) { } /// Creates a `pubspec.yaml` file with a flutter dependency. +/// +/// [platformSupport] is a map of platform string to the support details for +/// that platform. If empty, no `plugin` entry will be created unless `isPlugin` +/// is set to `true`. void createFakePubspec( Directory parent, { String name = 'fake_package', bool isFlutter = true, - PlatformSupport? androidSupport, - PlatformSupport? iosSupport, - PlatformSupport? linuxSupport, - PlatformSupport? macosSupport, - PlatformSupport? webSupport, - PlatformSupport? windowsSupport, + bool isPlugin = false, + Map platformSupport = + const {}, String publishTo = 'http://no_pub_server.com', String? version, }) { + isPlugin |= platformSupport.isNotEmpty; parent.childFile('pubspec.yaml').createSync(); String yaml = ''' name: $name +'''; + if (isFlutter) { + if (isPlugin) { + yaml += ''' flutter: plugin: platforms: '''; - if (androidSupport != null) { - yaml += _pluginPlatformSection('android', androidSupport, name); - } - if (iosSupport != null) { - yaml += _pluginPlatformSection('ios', iosSupport, name); - } - if (webSupport != null) { - yaml += _pluginPlatformSection('web', webSupport, name); - } - if (linuxSupport != null) { - yaml += _pluginPlatformSection('linux', linuxSupport, name); - } - if (macosSupport != null) { - yaml += _pluginPlatformSection('macos', macosSupport, name); - } - if (windowsSupport != null) { - yaml += _pluginPlatformSection('windows', windowsSupport, name); - } - if (isFlutter) { + for (final MapEntry platform + in platformSupport.entries) { + yaml += _pluginPlatformSection(platform.key, platform.value, name); + } + } + yaml += ''' dependencies: flutter: @@ -177,34 +180,34 @@ String _pluginPlatformSection( '''; } switch (platform) { - case 'android': + case kPlatformAndroid: return ''' android: package: io.flutter.plugins.fake pluginClass: FakePlugin '''; - case 'ios': + case kPlatformIos: return ''' ios: pluginClass: FLTFakePlugin '''; - case 'linux': + case kPlatformLinux: return ''' linux: pluginClass: FakePlugin '''; - case 'macos': + case kPlatformMacos: return ''' macos: pluginClass: FakePlugin '''; - case 'web': + case kPlatformWeb: return ''' web: pluginClass: FakePlugin fileName: ${packageName}_web.dart '''; - case 'windows': + case kPlatformWindows: return ''' windows: pluginClass: FakePlugin diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index a8e7e20bad24..6035360a221c 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -100,12 +100,12 @@ void main() { }); test('allows valid version', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '2.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -127,12 +127,12 @@ void main() { }); test('denies invalid version', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '0.2.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 0.0.1', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 0.2.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final Future> result = runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -152,12 +152,12 @@ void main() { }); test('allows valid version without explicit base-sha', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '2.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check']); @@ -171,11 +171,11 @@ void main() { }); test('allows valid version for new package.', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '1.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { - 'HEAD:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check']); @@ -190,12 +190,12 @@ void main() { }); test('allows likely reverts.', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '0.6.1'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 0.6.1', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check']); @@ -209,12 +209,12 @@ void main() { }); test('denies lower version that could not be a simple revert', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '0.5.1'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 0.5.1', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final Future> result = runCapturingPrint(runner, ['version-check']); @@ -226,12 +226,12 @@ void main() { }); test('denies invalid version without explicit base-sha', () async { - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '0.2.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.0.1', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 0.2.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final Future> result = runCapturingPrint(runner, ['version-check']); @@ -243,8 +243,8 @@ void main() { }); test('gracefully handles missing pubspec.yaml', () async { - final Directory pluginDir = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + final Directory pluginDir = + createFakePlugin('plugin', packagesDir, examples: []); gitDiffResponse = 'packages/plugin/pubspec.yaml'; pluginDir.childFile('pubspec.yaml').deleteSync(); final List output = await runCapturingPrint( @@ -265,14 +265,15 @@ void main() { }); test('allows minor changes to platform interfaces', () async { + const String newVersion = '1.1.0'; createFakePlugin('plugin_platform_interface', packagesDir, - includeChangeLog: true, includeVersion: true); + version: newVersion); gitDiffResponse = 'packages/plugin_platform_interface/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin_platform_interface/pubspec.yaml': 'version: 1.0.0', 'HEAD:packages/plugin_platform_interface/pubspec.yaml': - 'version: 1.1.0', + 'version: $newVersion', }; final List output = await runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -299,14 +300,15 @@ void main() { }); test('disallows breaking changes to platform interfaces', () async { + const String newVersion = '2.0.0'; createFakePlugin('plugin_platform_interface', packagesDir, - includeChangeLog: true, includeVersion: true); + version: newVersion); gitDiffResponse = 'packages/plugin_platform_interface/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin_platform_interface/pubspec.yaml': 'version: 1.0.0', 'HEAD:packages/plugin_platform_interface/pubspec.yaml': - 'version: 2.0.0', + 'version: $newVersion', }; final Future> output = runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -332,16 +334,11 @@ void main() { test('Allow empty lines in front of the first version in CHANGELOG', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); - - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); const String changelog = ''' - - - -## 1.0.1 - +## $version * Some changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -358,13 +355,10 @@ void main() { }); test('Throws if versions in changelog and pubspec do not match', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); - - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.1'); const String changelog = ''' ## 1.0.2 - * Some changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -392,13 +386,12 @@ The first version listed in CHANGELOG.md is 1.0.2. }); test('Success if CHANGELOG and pubspec versions match', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' -## 1.0.1 - +## $version * Some changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -417,17 +410,13 @@ The first version listed in CHANGELOG.md is 1.0.2. test( 'Fail if pubspec version only matches an older version listed in CHANGELOG', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.0'); - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.0'); const String changelog = ''' ## 1.0.1 - * Some changes. - ## 1.0.0 - * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -458,17 +447,14 @@ The first version listed in CHANGELOG.md is 1.0.1. test('Allow NEXT as a placeholder for gathering CHANGELOG entries', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String version = '1.0.0'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.0'); const String changelog = ''' ## NEXT - * Some changes that won't be published until the next time there's a release. - -## 1.0.0 - +## $version * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -486,21 +472,16 @@ The first version listed in CHANGELOG.md is 1.0.1. test('Fail if NEXT is left in the CHANGELOG when adding a version bump', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' -## 1.0.1 - +## $version * Some changes. - ## NEXT - * Some changes that should have been folded in 1.0.1. - ## 1.0.0 - * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -529,17 +510,13 @@ into the new version's release notes. }); test('Fail if the version changes without replacing NEXT', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.1'); - createFakePubspec(pluginDirectory, isFlutter: true, version: '1.0.1'); const String changelog = ''' ## NEXT - * Some changes that should be listed as part of 1.0.1. - ## 1.0.0 - * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); @@ -588,12 +565,12 @@ The first version listed in CHANGELOG.md is 1.0.0. 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '2.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check', '--base-sha=master', '--against-pub']); @@ -625,12 +602,12 @@ The first version listed in CHANGELOG.md is 1.0.0. 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '2.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; bool hasError = false; @@ -670,12 +647,12 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '2.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; bool hasError = false; final List result = await runCapturingPrint(runner, [ @@ -714,12 +691,12 @@ ${indentation}HTTP response: xx 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - createFakePlugin('plugin', packagesDir, - includeChangeLog: true, includeVersion: true); + const String newVersion = '2.0.0'; + createFakePlugin('plugin', packagesDir, version: newVersion); gitDiffResponse = 'packages/plugin/pubspec.yaml'; gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List result = await runCapturingPrint(runner, ['version-check', '--base-sha=master', '--against-pub']); diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index c0bd6b5dee50..050a4d4da73a 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -113,16 +113,11 @@ void main() { group('iOS', () { test('skip if iOS is not supported', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: false, - isMacOsPlugin: true); - - createFakePubspec(pluginDirectory.childDirectory('example'), - isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -135,17 +130,11 @@ void main() { }); test('skip if iOS is implemented in a federated package', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); - createFakePubspec(pluginDirectory, - iosSupport: PlatformSupport.federated); - - createFakePubspec(pluginDirectory.childDirectory('example'), - isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformIos: PlatformSupport.federated + }); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -158,25 +147,20 @@ void main() { }); test('running with correct destination, exclude 1 plugin', () async { - final Directory pluginDirectory1 = - createFakePlugin('plugin1', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); + createFakePlugin('plugin1', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); final Directory pluginDirectory2 = - createFakePlugin('plugin2', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); + createFakePlugin('plugin2', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); - final Directory pluginExampleDirectory1 = - pluginDirectory1.childDirectory('example'); - createFakePubspec(pluginExampleDirectory1, isFlutter: true); final Directory pluginExampleDirectory2 = pluginDirectory2.childDirectory('example'); - createFakePubspec(pluginExampleDirectory2, isFlutter: true); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -223,17 +207,15 @@ void main() { test('Not specifying --ios-destination assigns an available simulator', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); - final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); processRunner.processToReturn = mockProcess; @@ -276,16 +258,13 @@ void main() { group('macOS', () { test('skip if macOS is not supported', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isIosPlugin: true, - isMacOsPlugin: false); - - createFakePubspec(pluginDirectory.childDirectory('example'), - isFlutter: true); + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: >[ + ['example', 'test'], + ], + ); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -298,17 +277,11 @@ void main() { }); test('skip if macOS is implemented in a federated package', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isMacOsPlugin: true); - createFakePubspec(pluginDirectory, - macosSupport: PlatformSupport.federated); - - createFakePubspec(pluginDirectory.childDirectory('example'), - isFlutter: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformMacos: PlatformSupport.federated, + }); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -322,15 +295,14 @@ void main() { test('runs for macOS plugin', () async { final Directory pluginDirectory1 = - createFakePlugin('plugin', packagesDir, - withExtraFiles: >[ - ['example', 'test'], - ], - isMacOsPlugin: true); + createFakePlugin('plugin', packagesDir, extraFiles: >[ + ['example', 'test'], + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - createFakePubspec(pluginExampleDirectory, isFlutter: true); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); From 2f4618ed4b8716ef31d331f7f35d74df87c54559 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 17 Jun 2021 18:23:18 -0700 Subject: [PATCH 050/364] [flutter_plugin_tools] Simplify extraFiles in test utils (#4066) Rather than taking a list of list of path elements, just accept a list of Posix-style paths. In practice, the API was already being partially used that way. --- script/tool/test/analyze_command_test.dart | 21 +- .../test/build_examples_command_test.dart | 158 +++++++----- .../test/drive_examples_command_test.dart | 233 +++++++++++------- script/tool/test/firebase_test_lab_test.dart | 87 +++---- script/tool/test/java_test_command_test.dart | 12 +- .../tool/test/lint_podspecs_command_test.dart | 34 ++- script/tool/test/test_command_test.dart | 46 ++-- script/tool/test/util.dart | 26 +- script/tool/test/xctest_command_test.dart | 32 +-- 9 files changed, 343 insertions(+), 306 deletions(-) diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 84e3478f78c3..464aa1d91473 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -120,28 +120,24 @@ void main() { group('verifies analysis settings', () { test('fails analysis_options.yaml', () async { - createFakePlugin('foo', packagesDir, extraFiles: >[ - ['analysis_options.yaml'] - ]); + createFakePlugin('foo', packagesDir, + extraFiles: ['analysis_options.yaml']); await expectLater(() => runner.run(['analyze']), throwsA(const TypeMatcher())); }); test('fails .analysis_options', () async { - createFakePlugin('foo', packagesDir, extraFiles: >[ - ['.analysis_options'] - ]); + createFakePlugin('foo', packagesDir, + extraFiles: ['.analysis_options']); await expectLater(() => runner.run(['analyze']), throwsA(const TypeMatcher())); }); test('takes an allow list', () async { - final Directory pluginDir = - createFakePlugin('foo', packagesDir, extraFiles: >[ - ['analysis_options.yaml'] - ]); + final Directory pluginDir = createFakePlugin('foo', packagesDir, + extraFiles: ['analysis_options.yaml']); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -160,9 +156,8 @@ void main() { // See: https://github.com/flutter/flutter/issues/78994 test('takes an empty allow list', () async { - createFakePlugin('foo', packagesDir, extraFiles: >[ - ['analysis_options.yaml'] - ]); + createFakePlugin('foo', packagesDir, + extraFiles: ['analysis_options.yaml']); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index d3c51cfc4e3a..7fc97838c0ee 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -37,10 +37,8 @@ void main() { test('building for iOS when plugin is not set up for iOS results in no-op', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ]); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + extraFiles: ['example/test']); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -66,12 +64,16 @@ void main() { }); test('building for ios', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ], platformSupport: { - kPlatformIos: PlatformSupport.inline - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + ], + platformSupport: { + kPlatformIos: PlatformSupport.inline + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -113,8 +115,8 @@ void main() { 'building for Linux when plugin is not set up for Linux results in no-op', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ]); final Directory pluginExampleDirectory = @@ -141,12 +143,16 @@ void main() { }); test('building for Linux', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ], platformSupport: { - kPlatformLinux: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + ], + platformSupport: { + kPlatformLinux: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -176,8 +182,8 @@ void main() { test('building for macos with no implementation results in no-op', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ]); final Directory pluginExampleDirectory = @@ -204,13 +210,17 @@ void main() { }); test('building for macos', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ['example', 'macos', 'macos.swift'], - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + 'example/macos/macos.swift', + ], + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -239,8 +249,8 @@ void main() { test('building for web with no implementation results in no-op', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ]); final Directory pluginExampleDirectory = @@ -267,13 +277,17 @@ void main() { }); test('building for web', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ['example', 'web', 'index.html'], - ], platformSupport: { - kPlatformWeb: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + 'example/web/index.html', + ], + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -304,8 +318,8 @@ void main() { 'building for Windows when plugin is not set up for Windows results in no-op', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ]); final Directory pluginExampleDirectory = @@ -332,12 +346,16 @@ void main() { }); test('building for windows', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ], platformSupport: { - kPlatformWindows: PlatformSupport.inline - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + ], + platformSupport: { + kPlatformWindows: PlatformSupport.inline + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -368,8 +386,8 @@ void main() { 'building for Android when plugin is not set up for Android results in no-op', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ]); final Directory pluginExampleDirectory = @@ -396,12 +414,16 @@ void main() { }); test('building for android', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -433,12 +455,16 @@ void main() { }); test('enable-experiment flag for Android', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -462,12 +488,16 @@ void main() { }); test('enable-experiment flag for ios', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], - ], platformSupport: { - kPlatformIos: PlatformSupport.inline - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test', + ], + platformSupport: { + kPlatformIos: PlatformSupport.inline + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 8893110d1257..3175f7163546 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -36,14 +36,18 @@ void main() { }); test('driving under folder "test"', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test', 'plugin.dart'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test/plugin.dart', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -80,14 +84,18 @@ void main() { }); test('driving under folder "test_driver"', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -125,12 +133,17 @@ void main() { test('driving under folder "test_driver" when test files are missing"', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }); + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + ); await expectLater( () => runCapturingPrint(runner, ['drive-examples']), @@ -139,12 +152,17 @@ void main() { test('a plugin without any integration test files is reported as an error', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'lib', 'main.dart'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }); + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/lib/main.dart', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + ); await expectLater( () => runCapturingPrint(runner, ['drive-examples']), @@ -154,16 +172,20 @@ void main() { test( 'driving under folder "test_driver" when targets are under "integration_test"', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'integration_test.dart'], - ['example', 'integration_test', 'bar_test.dart'], - ['example', 'integration_test', 'foo_test.dart'], - ['example', 'integration_test', 'ignore_me.dart'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/integration_test.dart', + 'example/integration_test/bar_test.dart', + 'example/integration_test/foo_test.dart', + 'example/integration_test/ignore_me.dart', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -210,9 +232,9 @@ void main() { }); test('driving when plugin does not support Linux is a no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', ]); final List output = await runCapturingPrint(runner, [ @@ -236,13 +258,17 @@ void main() { }); test('driving on a Linux plugin', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], platformSupport: { - kPlatformLinux: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformLinux: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -282,9 +308,9 @@ void main() { }); test('driving when plugin does not suppport macOS is a no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', ]); final List output = await runCapturingPrint(runner, [ @@ -307,14 +333,18 @@ void main() { expect(processRunner.recordedCalls, []); }); test('driving on a macOS plugin', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ['example', 'macos', 'macos.swift'], - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + 'example/macos/macos.swift', + ], + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -354,9 +384,9 @@ void main() { }); test('driving when plugin does not suppport web is a no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', ]); final List output = await runCapturingPrint(runner, [ @@ -380,13 +410,17 @@ void main() { }); test('driving a web plugin', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], platformSupport: { - kPlatformWeb: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -428,9 +462,9 @@ void main() { }); test('driving when plugin does not suppport Windows is a no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', ]); final List output = await runCapturingPrint(runner, [ @@ -454,13 +488,17 @@ void main() { }); test('driving on a Windows plugin', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], platformSupport: { - kPlatformWindows: PlatformSupport.inline - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformWindows: PlatformSupport.inline + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -500,12 +538,17 @@ void main() { }); test('driving when plugin does not support mobile is no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test_driver', 'plugin.dart'], - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }, + ); final List output = await runCapturingPrint(runner, [ 'drive-examples', @@ -548,14 +591,18 @@ void main() { }); test('enable-experiment flag', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test_driver', 'plugin_test.dart'], - ['example', 'test', 'plugin.dart'], - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }); + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test/plugin.dart', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + ); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart index 8c39b8cf70e8..32867c949b4a 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_test.dart @@ -40,20 +40,13 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(1); processRunner.processToReturn = mockProcess; - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['lib/test/should_not_run_e2e.dart'], - ['example', 'test_driver', 'plugin_e2e.dart'], - ['example', 'test_driver', 'plugin_e2e_test.dart'], - ['example', 'android', 'gradlew'], - ['example', 'should_not_run_e2e.dart'], - [ - 'example', - 'android', - 'app', - 'src', - 'androidTest', - 'MainActivityTest.java' - ], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'lib/test/should_not_run_e2e.dart', + 'example/test_driver/plugin_e2e.dart', + 'example/test_driver/plugin_e2e_test.dart', + 'example/android/gradlew', + 'example/should_not_run_e2e.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', ]); await expectLater( () => runCapturingPrint(runner, ['firebase-test-lab']), @@ -65,26 +58,19 @@ void main() { }); test('runs e2e tests', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['test', 'plugin_test.dart'], - ['test', 'plugin_e2e.dart'], - ['should_not_run_e2e.dart'], - ['lib/test/should_not_run_e2e.dart'], - ['example', 'test', 'plugin_e2e.dart'], - ['example', 'test_driver', 'plugin_e2e.dart'], - ['example', 'test_driver', 'plugin_e2e_test.dart'], - ['example', 'integration_test', 'foo_test.dart'], - ['example', 'integration_test', 'should_not_run.dart'], - ['example', 'android', 'gradlew'], - ['example', 'should_not_run_e2e.dart'], - [ - 'example', - 'android', - 'app', - 'src', - 'androidTest', - 'MainActivityTest.java' - ], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'test/plugin_test.dart', + 'test/plugin_e2e.dart', + 'should_not_run_e2e.dart', + 'lib/test/should_not_run_e2e.dart', + 'example/test/plugin_e2e.dart', + 'example/test_driver/plugin_e2e.dart', + 'example/test_driver/plugin_e2e_test.dart', + 'example/integration_test/foo_test.dart', + 'example/integration_test/should_not_run.dart', + 'example/android/gradlew', + 'example/should_not_run_e2e.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', ]); await runCapturingPrint(runner, [ @@ -168,26 +154,19 @@ void main() { }); test('experimental flag', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['test', 'plugin_test.dart'], - ['test', 'plugin_e2e.dart'], - ['should_not_run_e2e.dart'], - ['lib/test/should_not_run_e2e.dart'], - ['example', 'test', 'plugin_e2e.dart'], - ['example', 'test_driver', 'plugin_e2e.dart'], - ['example', 'test_driver', 'plugin_e2e_test.dart'], - ['example', 'integration_test', 'foo_test.dart'], - ['example', 'integration_test', 'should_not_run.dart'], - ['example', 'android', 'gradlew'], - ['example', 'should_not_run_e2e.dart'], - [ - 'example', - 'android', - 'app', - 'src', - 'androidTest', - 'MainActivityTest.java' - ], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'test/plugin_test.dart', + 'test/plugin_e2e.dart', + 'should_not_run_e2e.dart', + 'lib/test/should_not_run_e2e.dart', + 'example/test/plugin_e2e.dart', + 'example/test_driver/plugin_e2e.dart', + 'example/test_driver/plugin_e2e_test.dart', + 'example/integration_test/foo_test.dart', + 'example/integration_test/should_not_run.dart', + 'example/android/gradlew', + 'example/should_not_run_e2e.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', ]); await runCapturingPrint(runner, [ diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index 3c6319ed447f..fc80961462c7 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -39,9 +39,9 @@ void main() { platformSupport: { kPlatformAndroid: PlatformSupport.inline }, - extraFiles: >[ - ['example/android', 'gradlew'], - ['android/src/test', 'example_test.java'], + extraFiles: [ + 'example/android/gradlew', + 'android/src/test/example_test.java', ], ); @@ -66,9 +66,9 @@ void main() { platformSupport: { kPlatformAndroid: PlatformSupport.inline }, - extraFiles: >[ - ['example/android', 'gradlew'], - ['example/android/app/src/test', 'example_test.java'], + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', ], ); diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 45dc92e69940..d86c9145fc19 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -45,9 +45,8 @@ void main() { }); test('only runs on macOS', () async { - createFakePlugin('plugin1', packagesDir, extraFiles: >[ - ['plugin1.podspec'], - ]); + createFakePlugin('plugin1', packagesDir, + extraFiles: ['plugin1.podspec']); mockPlatform.isMacOS = false; await runner.run(['podspecs']); @@ -59,11 +58,14 @@ void main() { }); test('runs pod lib lint on a podspec', () async { - final Directory plugin1Dir = - createFakePlugin('plugin1', packagesDir, extraFiles: >[ - ['ios', 'plugin1.podspec'], - ['bogus.dart'], // Ignore non-podspecs. - ]); + final Directory plugin1Dir = createFakePlugin( + 'plugin1', + packagesDir, + extraFiles: [ + 'ios/plugin1.podspec', + 'bogus.dart', // Ignore non-podspecs. + ], + ); processRunner.resultStdout = 'Foo'; processRunner.resultStderr = 'Bar'; @@ -106,12 +108,10 @@ void main() { }); test('skips podspecs with known issues', () async { - createFakePlugin('plugin1', packagesDir, extraFiles: >[ - ['plugin1.podspec'] - ]); - createFakePlugin('plugin2', packagesDir, extraFiles: >[ - ['plugin2.podspec'] - ]); + createFakePlugin('plugin1', packagesDir, + extraFiles: ['plugin1.podspec']); + createFakePlugin('plugin2', packagesDir, + extraFiles: ['plugin2.podspec']); await runner .run(['podspecs', '--skip=plugin1', '--skip=plugin2']); @@ -125,10 +125,8 @@ void main() { }); test('allow warnings for podspecs with known warnings', () async { - final Directory plugin1Dir = - createFakePlugin('plugin1', packagesDir, extraFiles: >[ - ['plugin1.podspec'], - ]); + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, + extraFiles: ['plugin1.podspec']); await runner.run(['podspecs', '--ignore-warnings=plugin1']); diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index b2f663f1e904..fdccae3d5520 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -31,14 +31,10 @@ void main() { }); test('runs flutter test on each plugin', () async { - final Directory plugin1Dir = - createFakePlugin('plugin1', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory plugin2Dir = - createFakePlugin('plugin2', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, + extraFiles: ['test/empty_test.dart']); + final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, + extraFiles: ['test/empty_test.dart']); await runner.run(['test']); @@ -55,10 +51,8 @@ void main() { test('skips testing plugins without test directory', () async { createFakePlugin('plugin1', packagesDir); - final Directory plugin2Dir = - createFakePlugin('plugin2', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, + extraFiles: ['test/empty_test.dart']); await runner.run(['test']); @@ -72,14 +66,10 @@ void main() { }); test('runs pub run test on non-Flutter packages', () async { - final Directory pluginDir = - createFakePlugin('a', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory packageDir = - createFakePackage('b', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory pluginDir = createFakePlugin('a', packagesDir, + extraFiles: ['test/empty_test.dart']); + final Directory packageDir = createFakePackage('b', packagesDir, + extraFiles: ['test/empty_test.dart']); await runner.run(['test', '--enable-experiment=exp1']); @@ -103,9 +93,7 @@ void main() { final Directory pluginDir = createFakePlugin( 'plugin', packagesDir, - extraFiles: >[ - ['test', 'empty_test.dart'], - ], + extraFiles: ['test/empty_test.dart'], platformSupport: { kPlatformWeb: PlatformSupport.inline, }, @@ -125,14 +113,10 @@ void main() { }); test('enable-experiment flag', () async { - final Directory pluginDir = - createFakePlugin('a', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); - final Directory packageDir = - createFakePackage('b', packagesDir, extraFiles: >[ - ['test', 'empty_test.dart'], - ]); + final Directory pluginDir = createFakePlugin('a', packagesDir, + extraFiles: ['test/empty_test.dart']); + final Directory packageDir = createFakePackage('b', packagesDir, + extraFiles: ['test/empty_test.dart']); await runner.run(['test', '--enable-experiment=exp1']); diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 4ced4eb48379..e71a26fa4ebb 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -13,6 +13,7 @@ import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; import 'package:quiver/collection.dart'; /// Creates a packages directory in the given location. @@ -36,11 +37,14 @@ Directory createPackagesDirectory( /// /// [platformSupport] is a map of platform string to the support details for /// that platform. +/// +/// [extraFiles] is an optional list of plugin-relative paths, using Posix +/// separators, of extra files to create in the plugin. Directory createFakePlugin( String name, Directory parentDirectory, { List examples = const ['example'], - List> extraFiles = const >[], + List extraFiles = const [], Map platformSupport = const {}, String? version = '0.0.1', @@ -60,22 +64,18 @@ Directory createFakePlugin( version: version, ); - final FileSystem fileSystem = pluginDirectory.fileSystem; - for (final List file in extraFiles) { - final List newFilePath = [pluginDirectory.path, ...file]; - final File newFile = fileSystem.file(fileSystem.path.joinAll(newFilePath)); - newFile.createSync(recursive: true); - } - return pluginDirectory; } /// Creates a plugin package with the given [name] in [packagesDirectory]. +/// +/// [extraFiles] is an optional list of package-relative paths, using unix-style +/// separators, of extra files to create in the package. Directory createFakePackage( String name, Directory parentDirectory, { List examples = const ['example'], - List> extraFiles = const >[], + List extraFiles = const [], bool isFlutter = false, String? version = '0.0.1', }) { @@ -105,8 +105,12 @@ Directory createFakePackage( } final FileSystem fileSystem = packageDirectory.fileSystem; - for (final List file in extraFiles) { - final List newFilePath = [packageDirectory.path, ...file]; + final p.Context posixContext = p.posix; + for (final String file in extraFiles) { + final List newFilePath = [ + packageDirectory.path, + ...posixContext.split(file) + ]; final File newFile = fileSystem.file(fileSystem.path.joinAll(newFilePath)); newFile.createSync(recursive: true); } diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 050a4d4da73a..08af85b39e4d 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -113,8 +113,8 @@ void main() { group('iOS', () { test('skip if iOS is not supported', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformMacos: PlatformSupport.inline, }); @@ -130,8 +130,8 @@ void main() { }); test('skip if iOS is implemented in a federated package', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformIos: PlatformSupport.federated }); @@ -147,14 +147,14 @@ void main() { }); test('running with correct destination, exclude 1 plugin', () async { - createFakePlugin('plugin1', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin1', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformIos: PlatformSupport.inline }); final Directory pluginDirectory2 = - createFakePlugin('plugin2', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin2', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformIos: PlatformSupport.inline }); @@ -207,8 +207,8 @@ void main() { test('Not specifying --ios-destination assigns an available simulator', () async { final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformIos: PlatformSupport.inline }); @@ -261,8 +261,8 @@ void main() { createFakePlugin( 'plugin', packagesDir, - extraFiles: >[ - ['example', 'test'], + extraFiles: [ + 'example/test', ], ); @@ -277,8 +277,8 @@ void main() { }); test('skip if macOS is implemented in a federated package', () async { - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformMacos: PlatformSupport.federated, }); @@ -295,8 +295,8 @@ void main() { test('runs for macOS plugin', () async { final Directory pluginDirectory1 = - createFakePlugin('plugin', packagesDir, extraFiles: >[ - ['example', 'test'], + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', ], platformSupport: { kPlatformMacos: PlatformSupport.inline, }); From a583e5b5b5e4eaca1cd300510d8ab317ea1a9330 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 17 Jun 2021 18:23:51 -0700 Subject: [PATCH 051/364] Update tool commands (#4065) Update CI and README to call the tool via bin/flutter_plugin_tools.dart rather than lib/src/main.dart to avoid a warning line on every run. --- .cirrus.yml | 4 ++-- script/tool/README.md | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index 0ba7fe3d5264..8d6f81acc51e 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -5,7 +5,7 @@ gcp_credentials: ENCRYPTED[ec898795b6f1b54f9cc2ab4104909f1053651f65fcab96397cfdc only_if: $CIRRUS_TAG == '' env: CHANNEL: "master" # Default to master when not explicitly set by a task. - PLUGIN_TOOLS: "dart run ./script/tool/lib/src/main.dart" + PLUGIN_TOOL: "./script/tool/bin/flutter_plugin_tools.dart" tool_setup_template: &TOOL_SETUP_TEMPLATE tool_setup_script: @@ -58,7 +58,7 @@ task: format_script: ./script/tool_runner.sh format --fail-on-change pubspec_script: ./script/tool_runner.sh pubspec-check license_script: - - dart script/tool/lib/src/main.dart license-check + - dart $PLUGIN_TOOL license-check - name: test env: matrix: diff --git a/script/tool/README.md b/script/tool/README.md index c0bcd7c5e102..c0ee8756e16b 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -26,7 +26,7 @@ cd ./script/tool && dart pub get && cd ../../ Run: ```sh -dart run ./script/tool/lib/src/main.dart +dart run ./script/tool/bin/flutter_plugin_tools.dart ``` ### Published Version @@ -58,21 +58,21 @@ Note that the `plugins` argument, despite the name, applies to any package. ```sh cd -dart run ./script/tool/lib/src/main.dart format --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart format --plugins plugin_name ``` ### Run the Dart Static Analyzer ```sh cd -dart run ./script/tool/lib/src/main.dart analyze --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart analyze --plugins plugin_name ``` ### Run Dart Unit Tests ```sh cd -dart run ./script/tool/lib/src/main.dart test --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart test --plugins plugin_name ``` ### Run XCTests @@ -80,9 +80,9 @@ dart run ./script/tool/lib/src/main.dart test --plugins plugin_name ```sh cd # For iOS: -dart run ./script/tool/lib/src/main.dart xctest --ios --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --ios --plugins plugin_name # For macOS: -dart run ./script/tool/lib/src/main.dart xctest --macos --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --macos --plugins plugin_name ``` ### Publish a Release @@ -90,11 +90,12 @@ dart run ./script/tool/lib/src/main.dart xctest --macos --plugins plugin_name ``sh cd git checkout -dart run ./script/tool/lib/src/main.dart publish-plugin --package +dart run ./script/tool/bin/flutter_plugin_tools.dart publish-plugin --package `` By default the tool tries to push tags to the `upstream` remote, but some -additional settings can be configured. Run `dart run ./script/tool/lib/src/main.dart publish-plugin --help` for more usage information. +additional settings can be configured. Run `dart run ./script/tool/bin/flutter_plugin_tools.dart +publish-plugin --help` for more usage information. The tool wraps `pub publish` for pushing the package to pub, and then will automatically use git to try to create and push tags. It has some additional From ff87585717e3cbf44d857035fb327a9f7836ecd2 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 18 Jun 2021 13:25:17 +0200 Subject: [PATCH 052/364] [in_app_purchase] Added BillingClient.isFeatureSupported (#4063) * Added BillingClient.isFeatureSupported * pubspec and changelog --- .../in_app_purchase_android/CHANGELOG.md | 4 ++ .../inapppurchase/InAppPurchasePlugin.java | 1 + .../inapppurchase/MethodCallHandlerImpl.java | 12 ++++ .../inapppurchase/MethodCallHandlerTest.java | 39 +++++++++++++ .../example/lib/main.dart | 57 +++++++++++++++++++ .../billing_client_wrapper.dart | 38 +++++++++++++ .../enum_converters.dart | 40 ++++++++++--- .../enum_converters.g.dart | 14 ++++- ...pp_purchase_android_platform_addition.dart | 6 ++ .../in_app_purchase_android/pubspec.yaml | 2 +- .../billing_client_wrapper_test.dart | 30 ++++++++++ ...rchase_android_platform_addition_test.dart | 30 ++++++++++ 12 files changed, 262 insertions(+), 11 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 61754627a595..526fdbac4ad4 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.3 + +Added support for isFeatureSupported in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition. + ## 0.1.2 * Added support for the obfuscatedAccountId and obfuscatedProfileId in the PurchaseWrapper. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index e4719f030d53..ad5343938be7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -38,6 +38,7 @@ static final class MethodNames { "BillingClient#consumeAsync(String, ConsumeResponseListener)"; static final String ACKNOWLEDGE_PURCHASE = "BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)"; + static final String IS_FEATURE_SUPPORTED = "BillingClient#isFeatureSupported(String)"; private MethodNames() {}; } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index cfcb81ae05b5..473c21d3ba5b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -145,6 +145,9 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { case InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE: acknowledgePurchase((String) call.argument("purchaseToken"), result); break; + case InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED: + isFeatureSupported((String) call.argument("feature"), result); + break; default: result.notImplemented(); } @@ -379,4 +382,13 @@ private boolean billingClientError(MethodChannel.Result result) { result.error("UNAVAILABLE", "BillingClient is unset. Try reconnecting.", null); return true; } + + private void isFeatureSupported(String feature, MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + assert billingClient != null; + BillingResult billingResult = billingClient.isFeatureSupported(feature); + result.success(billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK); + } } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index 4d7a02220cf5..7465e6a56250 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -7,6 +7,7 @@ import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.END_CONNECTION; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT; @@ -729,6 +730,44 @@ public void endConnection_if_activity_dettached() { verify(mockBillingClient).endConnection(); } + @Test + public void isFutureSupported_true() { + mockStartConnection(); + final String feature = "subscriptions"; + Map arguments = new HashMap<>(); + arguments.put("feature", feature); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.OK) + .setDebugMessage("dummy debug message") + .build(); + + MethodCall call = new MethodCall(IS_FEATURE_SUPPORTED, arguments); + when(mockBillingClient.isFeatureSupported(feature)).thenReturn(billingResult); + methodChannelHandler.onMethodCall(call, result); + verify(result).success(true); + } + + @Test + public void isFutureSupported_false() { + mockStartConnection(); + final String feature = "subscriptions"; + Map arguments = new HashMap<>(); + arguments.put("feature", feature); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED) + .setDebugMessage("dummy debug message") + .build(); + + MethodCall call = new MethodCall(IS_FEATURE_SUPPORTED, arguments); + when(mockBillingClient.isFeatureSupported(feature)).thenReturn(billingResult); + methodChannelHandler.onMethodCall(call, result); + verify(result).success(false); + } + private ArgumentCaptor mockStartConnection() { Map arguments = new HashMap<>(); arguments.put("handle", 1); diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart index c5726c4ade76..cb8cb75185e9 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -147,6 +147,7 @@ class _MyAppState extends State<_MyApp> { _buildConnectionCheckTile(), _buildProductList(), _buildConsumableBox(), + _FeatureCard(), ], ), ); @@ -434,3 +435,59 @@ class _MyAppState extends State<_MyApp> { return oldSubscription; } } + +class _FeatureCard extends StatelessWidget { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance + as InAppPurchaseAndroidPlatformAddition; + + _FeatureCard({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile(title: Text('Available features')), + Divider(), + for (BillingClientFeature feature in BillingClientFeature.values) + _buildFeatureWidget(feature), + ])); + } + + Widget _buildFeatureWidget(BillingClientFeature feature) { + return FutureBuilder( + future: addition.isFeatureSupported(feature), + builder: (context, snapshot) { + Color color = Colors.grey; + bool? data = snapshot.data; + if (data != null) { + color = data ? Colors.green : Colors.red; + } + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 4.0, 16.0, 4.0), + child: Text( + _featureToString(feature), + style: TextStyle(color: color), + ), + ); + }, + ); + } + + String _featureToString(BillingClientFeature feature) { + switch (feature) { + case BillingClientFeature.inAppItemsOnVR: + return 'inAppItemsOnVR'; + case BillingClientFeature.priceChangeConfirmation: + return 'priceChangeConfirmation'; + case BillingClientFeature.subscriptions: + return 'subscriptions'; + case BillingClientFeature.subscriptionsOnVR: + return 'subscriptionsOnVR'; + case BillingClientFeature.subscriptionsUpdate: + return 'subscriptionsUpdate'; + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index 1f43b3a8fbdd..cf08fa95a580 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -301,6 +301,16 @@ class BillingClient { {}); } + /// Checks if the specified feature or capability is supported by the Play Store. + /// Call this to check if a [BillingClientFeature] is supported by the device. + Future isFeatureSupported(BillingClientFeature feature) async { + var result = await channel.invokeMethod( + 'BillingClient#isFeatureSupported(String)', { + 'feature': BillingClientFeatureConverter().toJson(feature), + }); + return result ?? false; + } + /// The method call handler for [channel]. @visibleForTesting Future callHandler(MethodCall call) async { @@ -446,3 +456,31 @@ enum ProrationMode { @JsonValue(4) deferred, } + +/// Features/capabilities supported by [BillingClient.isFeatureSupported()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.FeatureType). +enum BillingClientFeature { + // WARNING: Changes to this class need to be reflected in our generated code. + // Run `flutter packages pub run build_runner watch` to rebuild and watch for + // further changes. + + // JsonValues need to match constant values defined in https://developer.android.com/reference/com/android/billingclient/api/BillingClient.FeatureType#summary + /// Purchase/query for in-app items on VR. + @JsonValue('inAppItemsOnVr') + inAppItemsOnVR, + + /// Launch a price change confirmation flow. + @JsonValue('priceChangeConfirmation') + priceChangeConfirmation, + + /// Purchase/query for subscriptions. + @JsonValue('subscriptions') + subscriptions, + + /// Purchase/query for subscriptions on VR. + @JsonValue('subscriptionsOnVr') + subscriptionsOnVR, + + /// Subscriptions update/replace. + @JsonValue('subscriptionsUpdate') + subscriptionsUpdate +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart index 46d6843af846..7ff333098fcc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart @@ -72,15 +72,6 @@ class ProrationModeConverter implements JsonConverter { int toJson(ProrationMode object) => _$ProrationModeEnumMap[object]!; } -// Define a class so we generate serializer helper methods for the enums -@JsonSerializable() -class _SerializedEnums { - late BillingResponse response; - late SkuType type; - late PurchaseStateWrapper purchaseState; - late ProrationMode prorationMode; -} - /// Serializer for [PurchaseStateWrapper]. /// /// Use these in `@JsonSerializable()` classes by annotating them with @@ -118,3 +109,34 @@ class PurchaseStateConverter } } } + +/// Serializer for [BillingClientFeature]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@BillingClientFeatureConverter()`. +class BillingClientFeatureConverter + implements JsonConverter { + /// Default const constructor. + const BillingClientFeatureConverter(); + + @override + BillingClientFeature fromJson(String json) { + return _$enumDecode( + _$BillingClientFeatureEnumMap.cast(), + json); + } + + @override + String toJson(BillingClientFeature object) => + _$BillingClientFeatureEnumMap[object]!; +} + +// Define a class so we generate serializer helper methods for the enums +@JsonSerializable() +class _SerializedEnums { + late BillingResponse response; + late SkuType type; + late PurchaseStateWrapper purchaseState; + late ProrationMode prorationMode; + late BillingClientFeature billingClientFeature; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart index 4186a2a24252..8d667d035196 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart @@ -13,7 +13,9 @@ _SerializedEnums _$_SerializedEnumsFromJson(Map json) { ..purchaseState = _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']) ..prorationMode = - _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']); + _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']) + ..billingClientFeature = _$enumDecode( + _$BillingClientFeatureEnumMap, json['billingClientFeature']); } Map _$_SerializedEnumsToJson(_SerializedEnums instance) => @@ -22,6 +24,8 @@ Map _$_SerializedEnumsToJson(_SerializedEnums instance) => 'type': _$SkuTypeEnumMap[instance.type], 'purchaseState': _$PurchaseStateWrapperEnumMap[instance.purchaseState], 'prorationMode': _$ProrationModeEnumMap[instance.prorationMode], + 'billingClientFeature': + _$BillingClientFeatureEnumMap[instance.billingClientFeature], }; K _$enumDecode( @@ -83,3 +87,11 @@ const _$ProrationModeEnumMap = { ProrationMode.immediateWithoutProration: 3, ProrationMode.deferred: 4, }; + +const _$BillingClientFeatureEnumMap = { + BillingClientFeature.inAppItemsOnVR: 'inAppItemsOnVr', + BillingClientFeature.priceChangeConfirmation: 'priceChangeConfirmation', + BillingClientFeature.subscriptions: 'subscriptions', + BillingClientFeature.subscriptionsOnVR: 'subscriptionsOnVr', + BillingClientFeature.subscriptionsUpdate: 'subscriptionsUpdate', +}; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index 84f8b9ef1787..fc4ab7cbf7dc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -135,4 +135,10 @@ class InAppPurchaseAndroidPlatformAddition return QueryPurchaseDetailsResponse( pastPurchases: pastPurchases, error: error); } + + /// Checks if the specified feature or capability is supported by the Play Store. + /// Call this to check if a [BillingClientFeature] is supported by the device. + Future isFeatureSupported(BillingClientFeature feature) async { + return _billingClient.isFeatureSupported(feature); + } } diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 900fa4374bd0..58069b06d1cf 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.2 +version: 0.1.3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index ec7289735ade..6ab1641984e9 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -544,4 +544,34 @@ void main() { debugMessage: kInvalidBillingResultErrorMessage))); }); }); + + group('isFeatureSupported', () { + const String isFeatureSupportedMethodName = + 'BillingClient#isFeatureSupported(String)'; + test('isFeatureSupported returns false', () async { + late Map arguments; + stubPlatform.addResponse( + name: isFeatureSupportedMethodName, + value: false, + additionalStepBeforeReturn: (value) => arguments = value, + ); + final bool isSupported = await billingClient + .isFeatureSupported(BillingClientFeature.subscriptions); + expect(isSupported, isFalse); + expect(arguments['feature'], equals('subscriptions')); + }); + + test('isFeatureSupported returns true', () async { + late Map arguments; + stubPlatform.addResponse( + name: isFeatureSupportedMethodName, + value: true, + additionalStepBeforeReturn: (value) => arguments = value, + ); + final bool isSupported = await billingClient + .isFeatureSupported(BillingClientFeature.subscriptions); + expect(isSupported, isTrue); + expect(arguments['feature'], equals('subscriptions')); + }); + }); } diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart index 36958d277f18..0ef17e7eed33 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -141,4 +141,34 @@ void main() { }); }); }); + + group('isFeatureSupported', () { + const String isFeatureSupportedMethodName = + 'BillingClient#isFeatureSupported(String)'; + test('isFeatureSupported returns false', () async { + late Map arguments; + stubPlatform.addResponse( + name: isFeatureSupportedMethodName, + value: false, + additionalStepBeforeReturn: (value) => arguments = value, + ); + final bool isSupported = await iapAndroidPlatformAddition + .isFeatureSupported(BillingClientFeature.subscriptions); + expect(isSupported, isFalse); + expect(arguments['feature'], equals('subscriptions')); + }); + + test('isFeatureSupported returns true', () async { + late Map arguments; + stubPlatform.addResponse( + name: isFeatureSupportedMethodName, + value: true, + additionalStepBeforeReturn: (value) => arguments = value, + ); + final bool isSupported = await iapAndroidPlatformAddition + .isFeatureSupported(BillingClientFeature.subscriptions); + expect(isSupported, isTrue); + expect(arguments['feature'], equals('subscriptions')); + }); + }); } From 0e69db3ae57c7bba8cbab1435cf539aa85cebcf6 Mon Sep 17 00:00:00 2001 From: Ahmed Ashour Date: Fri, 18 Jun 2021 18:34:12 +0200 Subject: [PATCH 053/364] [image_picker] remove unused imports from example (#4069) --- packages/image_picker/image_picker/example/lib/main.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/image_picker/image_picker/example/lib/main.dart b/packages/image_picker/image_picker/example/lib/main.dart index ff9f4a03cebc..698de1d98898 100755 --- a/packages/image_picker/image_picker/example/lib/main.dart +++ b/packages/image_picker/image_picker/example/lib/main.dart @@ -9,8 +9,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/src/widgets/basic.dart'; -import 'package:flutter/src/widgets/container.dart'; import 'package:image_picker/image_picker.dart'; import 'package:video_player/video_player.dart'; From 7354650d687ecbf831d41a334e2121063e447a10 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Fri, 18 Jun 2021 11:44:04 -0700 Subject: [PATCH 054/364] [in_app_purchase_android] add payment proxy (#4055) --- .../in_app_purchase_android/CHANGELOG.md | 3 +- .../android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../inapppurchase/InAppPurchasePlugin.java | 10 ++++- .../InAppPurchasePluginTest.java | 45 +++++++++++++++++++ .../example/android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 526fdbac4ad4..fefdecd3b71a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.1.3 -Added support for isFeatureSupported in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition. +* Add payment proxy. +* Added support for isFeatureSupported in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition. ## 0.1.2 diff --git a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle index 62b7a18d7931..eeac168068f7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:4.1.0' } } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties index 73eba353b126..baf2285f8c53 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index ad5343938be7..b96880571b9a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -18,6 +18,13 @@ /** Wraps a {@link BillingClient} instance and responds to Dart calls for it. */ public class InAppPurchasePlugin implements FlutterPlugin, ActivityAware { + static final String PROXY_PACKAGE_KEY = "PROXY_PACKAGE"; + // The proxy value has to match the value in library's AndroidManifest.xml. + // This is important that the is not changed, so we hard code the value here then having + // a unit test to make sure. If there is a strong reason to change the value, please inform the + // code owner of this package. + static final String PROXY_VALUE = "io.flutter.plugins.inapppurchase"; + @VisibleForTesting static final class MethodNames { static final String IS_READY = "BillingClient#isReady()"; @@ -50,7 +57,7 @@ static final class MethodNames { @SuppressWarnings("deprecation") public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) { InAppPurchasePlugin plugin = new InAppPurchasePlugin(); - plugin.setupMethodChannel(registrar.activity(), registrar.messenger(), registrar.context()); + registrar.activity().getIntent().putExtra(PROXY_PACKAGE_KEY, PROXY_VALUE); ((Application) registrar.context().getApplicationContext()) .registerActivityLifecycleCallbacks(plugin.methodCallHandler); } @@ -68,6 +75,7 @@ public void onDetachedFromEngine(FlutterPlugin.FlutterPluginBinding binding) { @Override public void onAttachedToActivity(ActivityPluginBinding binding) { + binding.getActivity().getIntent().putExtra(PROXY_PACKAGE_KEY, PROXY_VALUE); methodCallHandler.setActivity(binding.getActivity()); } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java index bcee5428eac9..ad7633903275 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java @@ -4,24 +4,35 @@ package io.flutter.plugins.inapppurchase; +import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.when; import android.app.Activity; import android.app.Application; import android.content.Context; +import android.content.Intent; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.PluginRegistry; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; public class InAppPurchasePluginTest { + + static final String PROXY_PACKAGE_KEY = "PROXY_PACKAGE"; + @Mock Activity activity; @Mock Context context; @Mock PluginRegistry.Registrar mockRegistrar; // For v1 embedding @Mock BinaryMessenger mockMessenger; @Mock Application mockApplication; + @Mock Intent mockIntent; + @Mock ActivityPluginBinding activityPluginBinding; + @Mock FlutterPlugin.FlutterPluginBinding flutterPluginBinding; @Before public void setUp() { @@ -29,6 +40,10 @@ public void setUp() { when(mockRegistrar.activity()).thenReturn(activity); when(mockRegistrar.messenger()).thenReturn(mockMessenger); when(mockRegistrar.context()).thenReturn(context); + when(activity.getIntent()).thenReturn(mockIntent); + when(activityPluginBinding.getActivity()).thenReturn(activity); + when(flutterPluginBinding.getBinaryMessenger()).thenReturn(mockMessenger); + when(flutterPluginBinding.getApplicationContext()).thenReturn(context); } @Test @@ -37,4 +52,34 @@ public void registerWith_doNotCrashWhenRegisterContextIsActivity_V1Embedding() { when(activity.getApplicationContext()).thenReturn(mockApplication); InAppPurchasePlugin.registerWith(mockRegistrar); } + + // The PROXY_PACKAGE_KEY value of this test (io.flutter.plugins.inapppurchase) should never be changed. + // In case there's a strong reason to change it, please inform the current code owner of the plugin. + @Test + public void registerWith_proxyIsSet_V1Embedding() { + when(mockRegistrar.context()).thenReturn(activity); + when(activity.getApplicationContext()).thenReturn(mockApplication); + InAppPurchasePlugin.registerWith(mockRegistrar); + // The `PROXY_PACKAGE_KEY` value is hard coded in the plugin code as "io.flutter.plugins.inapppurchase". + // We cannot use `BuildConfig.LIBRARY_PACKAGE_NAME` directly in the plugin code because whether to read BuildConfig.APPLICATION_ID or LIBRARY_PACKAGE_NAME + // depends on the "APP's" Android Gradle plugin version. Newer versions of AGP use LIBRARY_PACKAGE_NAME, whereas older ones use BuildConfig.APPLICATION_ID. + Mockito.verify(mockIntent).putExtra(PROXY_PACKAGE_KEY, "io.flutter.plugins.inapppurchase"); + assertEquals("io.flutter.plugins.inapppurchase", BuildConfig.LIBRARY_PACKAGE_NAME); + } + + // The PROXY_PACKAGE_KEY value of this test (io.flutter.plugins.inapppurchase) should never be changed. + // In case there's a strong reason to change it, please inform the current code owner of the plugin. + @Test + public void attachToActivity_proxyIsSet_V2Embedding() { + InAppPurchasePlugin plugin = new InAppPurchasePlugin(); + plugin.onAttachedToEngine(flutterPluginBinding); + plugin.onAttachedToActivity(activityPluginBinding); + // The `PROXY_PACKAGE_KEY` value is hard coded in the plugin code as "io.flutter.plugins.inapppurchase". + // We cannot use `BuildConfig.LIBRARY_PACKAGE_NAME` directly in the plugin code because whether to read BuildConfig.APPLICATION_ID or LIBRARY_PACKAGE_NAME + // depends on the "APP's" Android Gradle plugin version. Newer versions of AGP use LIBRARY_PACKAGE_NAME, whereas older ones use BuildConfig.APPLICATION_ID. + Mockito.verify(mockIntent).putExtra(PROXY_PACKAGE_KEY, "io.flutter.plugins.inapppurchase"); + assertEquals("io.flutter.plugins.inapppurchase", BuildConfig.LIBRARY_PACKAGE_NAME); + } } +// We cannot use `BuildConfig.LIBRARY_PACKAGE_NAME` directly in the plugin code because whether to read BuildConfig.APPLICATION_ID or LIBRARY_PACKAGE_NAME +// depends on the "APP's" Android Gradle plugin version. Newer versions of AGP use LIBRARY_PACKAGE_NAME, whereas older ones use BuildConfig.APPLICATION_ID. diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle index e101ac08df55..0b4cf534e0aa 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:4.1.0' } } diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties index 2819f022f1fd..bc6a58afdda2 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip From e83a6833c98f13009c73cb19cc5da5e85dbb130f Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Fri, 18 Jun 2021 16:59:23 -0700 Subject: [PATCH 055/364] [in_app_purchase_android] fix version for 'add payment proxy': pull/4055 (#4071) --- .../in_app_purchase/in_app_purchase_android/CHANGELOG.md | 5 ++++- .../in_app_purchase/in_app_purchase_android/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index fefdecd3b71a..9066fab84d18 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,6 +1,9 @@ -## 0.1.3 +## 0.1.3+1 * Add payment proxy. + +## 0.1.3 + * Added support for isFeatureSupported in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition. ## 0.1.2 diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 58069b06d1cf..ad06d85a6d43 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3 +version: 0.1.3+1 environment: sdk: ">=2.12.0 <3.0.0" From 1b0f23db05b7d6e8b13e36ac4da1659ed2c83265 Mon Sep 17 00:00:00 2001 From: keyonghan <54558023+keyonghan@users.noreply.github.com> Date: Mon, 21 Jun 2021 16:35:23 -0700 Subject: [PATCH 056/364] update gcp credentials (#4076) --- .cirrus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cirrus.yml b/.cirrus.yml index 8d6f81acc51e..5e685c974c9d 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -1,4 +1,4 @@ -gcp_credentials: ENCRYPTED[ec898795b6f1b54f9cc2ab4104909f1053651f65fcab96397cfdc33dae6df5fd0fa972e29ba19f4f95125de844ab1641] +gcp_credentials: ENCRYPTED[!0e63b52bd7e4fda1cd7b7bf2b4fe515a27fadbeaced01f5ad8b699b81d3611ed64c5d3271bcd8426dd914ef41cba48a0!] # Don't run on release tags since it creates O(n^2) tasks where n is the # number of plugins From 67885c5955ece662abef5893e7f1301b3b8ab73f Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Tue, 22 Jun 2021 11:29:37 -0700 Subject: [PATCH 057/364] [in_app_purchase] update gradle tool to 4.1.0 (#4079) --- .../in_app_purchase/example/android/build.gradle | 2 +- .../example/android/gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase/example/android/build.gradle b/packages/in_app_purchase/in_app_purchase/example/android/build.gradle index e101ac08df55..0b4cf534e0aa 100644 --- a/packages/in_app_purchase/in_app_purchase/example/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:4.1.0' } } diff --git a/packages/in_app_purchase/in_app_purchase/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase/example/android/gradle/wrapper/gradle-wrapper.properties index 2819f022f1fd..bc6a58afdda2 100644 --- a/packages/in_app_purchase/in_app_purchase/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/in_app_purchase/in_app_purchase/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip From e86417239ba844801bb8b7b90b7dba9f21b6740f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl?= <32639467+danielroek@users.noreply.github.com> Date: Tue, 22 Jun 2021 20:56:04 +0200 Subject: [PATCH 058/364] [image_picker] Multiple image support (#3783) --- .../image_picker/image_picker/CHANGELOG.md | 11 +- packages/image_picker/image_picker/README.md | 7 + .../imagepicker/ImagePickerDelegate.java | 90 ++- .../imagepicker/ImagePickerPlugin.java | 4 + .../imagepicker/ImagePickerDelegateTest.java | 9 + .../imagepicker/ImagePickerPluginTest.java | 13 + .../ios/RunnerTests/ImagePickerPluginTests.m | 75 +- .../ios/Classes/FLTImagePickerPlugin.m | 149 ++-- .../FLTPHPickerSaveImageToPathOperation.h | 31 + .../FLTPHPickerSaveImageToPathOperation.m | 132 ++++ .../image_picker/lib/image_picker.dart | 33 + .../image_picker/image_picker/pubspec.yaml | 4 +- .../image_picker/test/image_picker_test.dart | 667 ++++++++++-------- 13 files changed, 854 insertions(+), 371 deletions(-) create mode 100644 packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h create mode 100644 packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index d9127e24d2af..7147c0f6f7de 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.8.1 + +* Add a new method `getMultiImage` to allow picking multiple images on iOS 14 or higher +and Android 4.3 or higher. Returns only 1 image for lower versions of iOS and Android. +* Known issue: On Android, `getLostData` will only get the last picked image when picking multiple images, +see: [#84634](https://github.com/flutter/flutter/issues/84634). + ## 0.8.0+4 * Cleaned up the README example @@ -49,7 +56,7 @@ is not included selected photos and image is scaled. ## 0.7.3 -* Endorse image_picker_for_web +* Endorse image_picker_for_web. ## 0.7.2+1 @@ -57,7 +64,7 @@ is not included selected photos and image is scaled. ## 0.7.2 -* Run CocoaPods iOS tests in RunnerUITests target +* Run CocoaPods iOS tests in RunnerUITests target. ## 0.7.1 diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index 10899e2d85fb..3b3746d9f63e 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -11,6 +11,9 @@ First, add `image_picker` as a [dependency in your pubspec.yaml file](https://fl ### iOS +Starting with version **0.8.1** the iOS implementation uses PHPicker to pick (multiple) images on iOS 14 or higher. +As a result of implementing PHPicker it becomes impossible to pick HEIC images on the iOS simulator in iOS 14+. This is a known issue. Please test this on a real device, or test with non-HEIC images until Apple solves this issue.[63426347 - Apple known issue](https://www.google.com/search?q=63426347+apple&sxsrf=ALeKk01YnTMid5S0PYvhL8GbgXJ40ZS[…]t=gws-wiz&ved=0ahUKEwjKh8XH_5HwAhWL_rsIHUmHDN8Q4dUDCA8&uact=5) + Add the following keys to your _Info.plist_ file, located in `/ios/Runner/Info.plist`: * `NSPhotoLibraryUsageDescription` - describe why your app needs permission for the photo library. This is called _Privacy - Photo Library Usage Description_ in the visual editor. @@ -19,6 +22,8 @@ Add the following keys to your _Info.plist_ file, located in `/ios ### Android +Starting with version **0.8.1** the Android implementation support to pick (multiple) images on Android 4.3 or higher. + No configuration required - the plugin should work out of the box. It is no longer required to add `android:requestLegacyExternalStorage="true"` as an attribute to the `` tag in AndroidManifest.xml, as `image_picker` has been updated to make use of scoped storage. @@ -63,6 +68,8 @@ Future retrieveLostData() async { There's no way to detect when this happens, so calling this method at the right place is essential. We recommend to wire this into some kind of start up check. Please refer to the example app to see how we used it. +On Android, `getLostData` will only get the last picked image when picking multiple images, see: [#84634](https://github.com/flutter/flutter/issues/84634). + ## Deprecation warnings in `pickImage`, `pickVideo` and `LostDataResponse` Starting with version **0.6.7** of the image_picker plugin, the API of the plugin changed slightly to allow for web implementations to exist. diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index c934b54a1f8e..c4a686f5ce13 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -22,6 +22,7 @@ import io.flutter.plugin.common.PluginRegistry; import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -75,6 +76,7 @@ public class ImagePickerDelegate @VisibleForTesting static final int REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY = 2342; @VisibleForTesting static final int REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA = 2343; @VisibleForTesting static final int REQUEST_CAMERA_IMAGE_PERMISSION = 2345; + @VisibleForTesting static final int REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY = 2346; @VisibleForTesting static final int REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY = 2352; @VisibleForTesting static final int REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA = 2353; @VisibleForTesting static final int REQUEST_CAMERA_VIDEO_PERMISSION = 2355; @@ -315,6 +317,15 @@ public void chooseImageFromGallery(MethodCall methodCall, MethodChannel.Result r launchPickImageFromGalleryIntent(); } + public void chooseMultiImageFromGallery(MethodCall methodCall, MethodChannel.Result result) { + if (!setPendingMethodCallAndResult(methodCall, result)) { + finishWithAlreadyActiveError(result); + return; + } + + launchMultiPickImageFromGalleryIntent(); + } + private void launchPickImageFromGalleryIntent() { Intent pickImageIntent = new Intent(Intent.ACTION_GET_CONTENT); pickImageIntent.setType("image/*"); @@ -322,6 +333,16 @@ private void launchPickImageFromGalleryIntent() { activity.startActivityForResult(pickImageIntent, REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY); } + private void launchMultiPickImageFromGalleryIntent() { + Intent pickImageIntent = new Intent(Intent.ACTION_GET_CONTENT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + pickImageIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + pickImageIntent.setType("image/*"); + + activity.startActivityForResult(pickImageIntent, REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY); + } + public void takeImageWithCamera(MethodCall methodCall, MethodChannel.Result result) { if (!setPendingMethodCallAndResult(methodCall, result)) { finishWithAlreadyActiveError(result); @@ -440,6 +461,9 @@ public boolean onActivityResult(int requestCode, int resultCode, Intent data) { case REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY: handleChooseImageResult(resultCode, data); break; + case REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY: + handleChooseMultiImageResult(resultCode, data); + break; case REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA: handleCaptureImageResult(resultCode); break; @@ -467,6 +491,24 @@ private void handleChooseImageResult(int resultCode, Intent data) { finishWithSuccess(null); } + private void handleChooseMultiImageResult(int resultCode, Intent intent) { + if (resultCode == Activity.RESULT_OK && intent != null) { + ArrayList paths = new ArrayList<>(); + if (intent.getClipData() != null) { + for (int i = 0; i < intent.getClipData().getItemCount(); i++) { + paths.add(fileUtils.getPathFromUri(activity, intent.getClipData().getItemAt(i).getUri())); + } + } else { + paths.add(fileUtils.getPathFromUri(activity, intent.getData())); + } + handleMultiImageResult(paths, false); + return; + } + + // User cancelled choosing a picture. + finishWithSuccess(null); + } + private void handleChooseVideoResult(int resultCode, Intent data) { if (resultCode == Activity.RESULT_OK && data != null) { String path = fileUtils.getPathFromUri(activity, data.getData()); @@ -516,26 +558,45 @@ public void onPathReady(String path) { finishWithSuccess(null); } - private void handleImageResult(String path, boolean shouldDeleteOriginalIfScaled) { + private void handleMultiImageResult( + ArrayList paths, boolean shouldDeleteOriginalIfScaled) { if (methodCall != null) { - Double maxWidth = methodCall.argument("maxWidth"); - Double maxHeight = methodCall.argument("maxHeight"); - Integer imageQuality = methodCall.argument("imageQuality"); - - String finalImagePath = - imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight, imageQuality); - - finishWithSuccess(finalImagePath); + for (int i = 0; i < paths.size(); i++) { + String finalImagePath = getResizedImagePath(paths.get(i)); + + //delete original file if scaled + if (finalImagePath != null + && !finalImagePath.equals(paths.get(i)) + && shouldDeleteOriginalIfScaled) { + new File(paths.get(i)).delete(); + } + paths.set(i, finalImagePath); + } + finishWithListSuccess(paths); + } + } + private void handleImageResult(String path, boolean shouldDeleteOriginalIfScaled) { + if (methodCall != null) { + String finalImagePath = getResizedImagePath(path); //delete original file if scaled if (finalImagePath != null && !finalImagePath.equals(path) && shouldDeleteOriginalIfScaled) { new File(path).delete(); } + finishWithSuccess(finalImagePath); } else { finishWithSuccess(path); } } + private String getResizedImagePath(String path) { + Double maxWidth = methodCall.argument("maxWidth"); + Double maxHeight = methodCall.argument("maxHeight"); + Integer imageQuality = methodCall.argument("imageQuality"); + + return imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight, imageQuality); + } + private void handleVideoResult(String path) { finishWithSuccess(path); } @@ -564,6 +625,17 @@ private void finishWithSuccess(String imagePath) { clearMethodCallAndResult(); } + private void finishWithListSuccess(ArrayList imagePaths) { + if (pendingResult == null) { + for (String imagePath : imagePaths) { + cache.saveResult(imagePath, null, null); + } + return; + } + pendingResult.success(imagePaths); + clearMethodCallAndResult(); + } + private void finishWithAlreadyActiveError(MethodChannel.Result result) { result.error("already_active", "Image picker is already active", null); } diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java index bffc903b531e..577675bd433a 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerPlugin.java @@ -91,6 +91,7 @@ public void onActivityStopped(Activity activity) { } static final String METHOD_CALL_IMAGE = "pickImage"; + static final String METHOD_CALL_MULTI_IMAGE = "pickMultiImage"; static final String METHOD_CALL_VIDEO = "pickVideo"; private static final String METHOD_CALL_RETRIEVE = "retrieve"; private static final int CAMERA_DEVICE_FRONT = 1; @@ -302,6 +303,9 @@ public void onMethodCall(MethodCall call, MethodChannel.Result rawResult) { throw new IllegalArgumentException("Invalid image source: " + imageSource); } break; + case METHOD_CALL_MULTI_IMAGE: + delegate.chooseMultiImageFromGallery(call, result); + break; case METHOD_CALL_VIDEO: imageSource = call.argument("source"); switch (imageSource) { diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index da53b10b50f5..f8be66833b17 100644 --- a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -104,6 +104,15 @@ public void chooseImageFromGallery_WhenPendingResultExists_FinishesWithAlreadyAc } @Test + public void chooseMultiImageFromGallery_WhenPendingResultExists_FinishesWithAlreadyActiveError() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.chooseMultiImageFromGallery(mockMethodCall, mockResult); + + verifyFinishedWithAlreadyActiveError(); + verifyNoMoreInteractions(mockResult); + } + public void chooseImageFromGallery_WhenHasExternalStoragePermission_LaunchesChooseFromGalleryIntent() { when(mockPermissionManager.isPermissionGranted(Manifest.permission.READ_EXTERNAL_STORAGE)) diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java index a0ce87f4f2b7..422b8be74f7c 100644 --- a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java +++ b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java @@ -32,6 +32,7 @@ public class ImagePickerPluginTest { private static final int SOURCE_CAMERA = 0; private static final int SOURCE_GALLERY = 1; private static final String PICK_IMAGE = "pickImage"; + private static final String PICK_MULTI_IMAGE = "pickMultiImage"; private static final String PICK_VIDEO = "pickVideo"; @Rule public ExpectedException exception = ExpectedException.none(); @@ -92,6 +93,14 @@ public void onMethodCall_WhenSourceIsGallery_InvokesChooseImageFromGallery() { verifyZeroInteractions(mockResult); } + @Test + public void onMethodCall_InvokesChooseMultiImageFromGallery() { + MethodCall call = buildMethodCall(PICK_MULTI_IMAGE); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).chooseMultiImageFromGallery(eq(call), any()); + verifyZeroInteractions(mockResult); + } + @Test public void onMethodCall_WhenSourceIsCamera_InvokesTakeImageWithCamera() { MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_CAMERA); @@ -173,4 +182,8 @@ private MethodCall buildMethodCall(String method, final int source) { return new MethodCall(method, arguments); } + + private MethodCall buildMethodCall(String method) { + return new MethodCall(method, null); + } } diff --git a/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m b/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m index 04ba4b98e241..f667526671f7 100644 --- a/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m +++ b/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m @@ -22,7 +22,7 @@ - (UIViewController *)presentedViewController { @interface FLTImagePickerPlugin (Test) @property(copy, nonatomic) FlutterResult result; -- (void)handleSavedPath:(NSString *)path; +- (void)handleSavedPathList:(NSMutableArray *)pathList; - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker; @end @@ -122,21 +122,6 @@ - (void)testPickingVideoWithDuration { XCTAssertEqual([plugin getImagePickerController].videoMaximumDuration, 95); } -- (void)testPluginPickImageSelectMultipleTimes { - FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; - FlutterMethodCall *call = - [FlutterMethodCall methodCallWithMethodName:@"pickImage" - arguments:@{@"source" : @(0), @"cameraDevice" : @(0)}]; - [plugin handleMethodCall:call - result:^(id _Nullable r){ - }]; - plugin.result = ^(id result) { - - }; - [plugin handleSavedPath:@"test"]; - [plugin handleSavedPath:@"test"]; -} - - (void)testViewController { UIWindow *window = [UIWindow new]; MockViewController *vc1 = [MockViewController new]; @@ -149,4 +134,62 @@ - (void)testViewController { XCTAssertEqual([plugin viewControllerWithWindow:window], vc2); } +- (void)testPluginMultiImagePathIsNil { + FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; + + dispatch_semaphore_t resultSemaphore = dispatch_semaphore_create(0); + __block FlutterError *pickImageResult = nil; + + plugin.result = ^(id _Nullable r) { + pickImageResult = r; + dispatch_semaphore_signal(resultSemaphore); + }; + [plugin handleSavedPathList:nil]; + + dispatch_semaphore_wait(resultSemaphore, DISPATCH_TIME_FOREVER); + + XCTAssertEqualObjects(pickImageResult.code, @"create_error"); +} + +- (void)testPluginMultiImagePathHasNullItem { + FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; + NSMutableArray *pathList = [NSMutableArray new]; + + [pathList addObject:[NSNull null]]; + + dispatch_semaphore_t resultSemaphore = dispatch_semaphore_create(0); + __block FlutterError *pickImageResult = nil; + + plugin.result = ^(id _Nullable r) { + pickImageResult = r; + dispatch_semaphore_signal(resultSemaphore); + }; + [plugin handleSavedPathList:pathList]; + + dispatch_semaphore_wait(resultSemaphore, DISPATCH_TIME_FOREVER); + + XCTAssertEqualObjects(pickImageResult.code, @"create_error"); +} + +- (void)testPluginMultiImagePathHasItem { + FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; + NSString *savedPath = @"test"; + NSMutableArray *pathList = [NSMutableArray new]; + + [pathList addObject:savedPath]; + + dispatch_semaphore_t resultSemaphore = dispatch_semaphore_create(0); + __block id pickImageResult = nil; + + plugin.result = ^(id _Nullable r) { + pickImageResult = r; + dispatch_semaphore_signal(resultSemaphore); + }; + [plugin handleSavedPathList:pathList]; + + dispatch_semaphore_wait(resultSemaphore, DISPATCH_TIME_FOREVER); + + XCTAssertEqual(pickImageResult, pathList); +} + @end diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m index e3df6413e9a8..7c91606ba535 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m @@ -14,6 +14,7 @@ #import "FLTImagePickerImageUtil.h" #import "FLTImagePickerMetaDataUtil.h" #import "FLTImagePickerPhotoAssetUtil.h" +#import "FLTPHPickerSaveImageToPathOperation.h" @interface FLTImagePickerPlugin () *)results API_AVAILABLE(ios(14)) { [picker dismissViewControllerAnimated:YES completion:nil]; + dispatch_queue_t backgroundQueue = + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0); + dispatch_async(backgroundQueue, ^{ + if (results.count == 0) { + self.result(nil); + self.result = nil; + self->_arguments = nil; + return; + } + NSNumber *maxWidth = [self->_arguments objectForKey:@"maxWidth"]; + NSNumber *maxHeight = [self->_arguments objectForKey:@"maxHeight"]; + NSNumber *imageQuality = [self->_arguments objectForKey:@"imageQuality"]; + NSNumber *desiredImageQuality = [self getDesiredImageQuality:imageQuality]; + NSOperationQueue *operationQueue = [NSOperationQueue new]; + NSMutableArray *pathList = [self createNSMutableArrayWithSize:results.count]; - NSNumber *maxWidth = [_arguments objectForKey:@"maxWidth"]; - NSNumber *maxHeight = [_arguments objectForKey:@"maxHeight"]; - NSNumber *imageQuality = [_arguments objectForKey:@"imageQuality"]; - NSNumber *desiredImageQuality = [self getDesiredImageQuality:imageQuality]; - - for (PHPickerResult *result in results) { - [result.itemProvider - loadObjectOfClass:[UIImage class] - completionHandler:^(__kindof id _Nullable image, - NSError *_Nullable error) { - if ([image isKindOfClass:[UIImage class]]) { - __block UIImage *localImage = image; - dispatch_async(dispatch_get_main_queue(), ^{ - PHAsset *originalAsset = - [FLTImagePickerPhotoAssetUtil getAssetFromPHPickerResult:result]; - - if (maxWidth != (id)[NSNull null] || maxHeight != (id)[NSNull null]) { - localImage = [FLTImagePickerImageUtil scaledImage:localImage - maxWidth:maxWidth - maxHeight:maxHeight - isMetadataAvailable:originalAsset != nil]; - } - - if (!originalAsset) { - // Image picked without an original asset (e.g. User took a photo directly) - [self saveImageWithPickerInfo:nil - image:localImage - imageQuality:desiredImageQuality]; - } else { - [[PHImageManager defaultManager] - requestImageDataForAsset:originalAsset - options:nil - resultHandler:^( - NSData *_Nullable imageData, NSString *_Nullable dataUTI, - UIImageOrientation orientation, NSDictionary *_Nullable info) { - // maxWidth and maxHeight are used only for GIF images. - [self saveImageWithOriginalImageData:imageData - image:localImage - maxWidth:maxWidth + for (int i = 0; i < results.count; i++) { + PHPickerResult *result = results[i]; + FLTPHPickerSaveImageToPathOperation *operation = + [[FLTPHPickerSaveImageToPathOperation alloc] initWithResult:result maxHeight:maxHeight - imageQuality:desiredImageQuality]; - }]; - } - }); - } - }]; - } + maxWidth:maxWidth + desiredImageQuality:desiredImageQuality + savedPathBlock:^(NSString *savedPath) { + pathList[i] = savedPath; + }]; + [operationQueue addOperation:operation]; + } + [operationQueue waitUntilAllOperationsAreFinished]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self handleSavedPathList:pathList]; + }); + }); +} + +/** + * Creates an NSMutableArray of a certain size filled with NSNull objects. + * + * The difference with initWithCapacity is that initWithCapacity still gives an empty array making + * it impossible to add objects on an index larger than the size. + * + * @param @size The length of the required array + * @return @NSMutableArray An array of a specified size + */ +- (NSMutableArray *)createNSMutableArrayWithSize:(NSUInteger)size { + NSMutableArray *mutableArray = [[NSMutableArray alloc] initWithCapacity:size]; + for (int i = 0; i < size; [mutableArray addObject:[NSNull null]], i++) + ; + return mutableArray; } - (void)imagePickerController:(UIImagePickerController *)picker @@ -504,7 +512,7 @@ - (void)saveImageWithOriginalImageData:(NSData *)originalImageData maxWidth:maxWidth maxHeight:maxHeight imageQuality:imageQuality]; - [self handleSavedPath:savedPath]; + [self handleSavedPathList:@[ savedPath ]]; } - (void)saveImageWithPickerInfo:(NSDictionary *)info @@ -513,18 +521,43 @@ - (void)saveImageWithPickerInfo:(NSDictionary *)info NSString *savedPath = [FLTImagePickerPhotoAssetUtil saveImageWithPickerInfo:info image:image imageQuality:imageQuality]; - [self handleSavedPath:savedPath]; + [self handleSavedPathList:@[ savedPath ]]; } -- (void)handleSavedPath:(NSString *)path { +/** + * Applies NSMutableArray on the FLutterResult. + * + * NSString must be returned by FlutterResult if the single image + * mode is active. It is checked by @c maxImagesAllowed and + * returns the first object of the @c pathlist. + * + * NSMutableArray must be returned by FlutterResult if the multi-image + * mode is active. After the @c pathlist count is checked then it returns + * the @c pathlist. + * + * @param @pathList that should be applied to FlutterResult. + */ +- (void)handleSavedPathList:(NSArray *)pathList { if (!self.result) { return; } - if (path) { - self.result(path); + + if (pathList) { + if (![pathList containsObject:[NSNull null]]) { + if ((self.maxImagesAllowed == 1)) { + self.result(pathList.firstObject); + } else { + self.result(pathList); + } + } else { + self.result([FlutterError errorWithCode:@"create_error" + message:@"pathList's items should not be null" + details:nil]); + } } else { + // This should never happen. self.result([FlutterError errorWithCode:@"create_error" - message:@"Temporary file could not be created" + message:@"pathList should not be nil" details:nil]); } self.result = nil; diff --git a/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h new file mode 100644 index 000000000000..7ba3d28ef3dd --- /dev/null +++ b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h @@ -0,0 +1,31 @@ +// Copyright 2013 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 +#import + +#import "FLTImagePickerImageUtil.h" +#import "FLTImagePickerMetaDataUtil.h" +#import "FLTImagePickerPhotoAssetUtil.h" + +/*! + @class FLTPHPickerSaveImageToPathOperation + + @brief The FLTPHPickerSaveImageToPathOperation class + + @discussion This class was implemented to handle saved image paths and populate the pathList + with the final result by using GetSavedPath type block. + + @superclass SuperClass: NSOperation\n + @helps It helps FLTImagePickerPlugin class. + */ +@interface FLTPHPickerSaveImageToPathOperation : NSOperation + +- (instancetype)initWithResult:(PHPickerResult *)result + maxHeight:(NSNumber *)maxHeight + maxWidth:(NSNumber *)maxWidth + desiredImageQuality:(NSNumber *)desiredImageQuality + savedPathBlock:(void (^)(NSString *))savedPathBlock API_AVAILABLE(ios(14)); + +@end diff --git a/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m new file mode 100644 index 000000000000..30da22774d07 --- /dev/null +++ b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m @@ -0,0 +1,132 @@ +// Copyright 2013 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 "FLTPHPickerSaveImageToPathOperation.h" + +API_AVAILABLE(ios(14)) +@interface FLTPHPickerSaveImageToPathOperation () + +@property(strong, nonatomic) PHPickerResult *result; +@property(assign, nonatomic) NSNumber *maxHeight; +@property(assign, nonatomic) NSNumber *maxWidth; +@property(assign, nonatomic) NSNumber *desiredImageQuality; + +@end + +typedef void (^GetSavedPath)(NSString *); + +@implementation FLTPHPickerSaveImageToPathOperation { + BOOL executing; + BOOL finished; + GetSavedPath getSavedPath; +} + +- (instancetype)initWithResult:(PHPickerResult *)result + maxHeight:(NSNumber *)maxHeight + maxWidth:(NSNumber *)maxWidth + desiredImageQuality:(NSNumber *)desiredImageQuality + savedPathBlock:(GetSavedPath)savedPathBlock API_AVAILABLE(ios(14)) { + if (self = [super init]) { + if (result) { + self.result = result; + self.maxHeight = maxHeight; + self.maxWidth = maxWidth; + self.desiredImageQuality = desiredImageQuality; + getSavedPath = savedPathBlock; + executing = NO; + finished = NO; + } else { + return nil; + } + return self; + } else { + return nil; + } +} + +- (BOOL)isConcurrent { + return YES; +} + +- (BOOL)isExecuting { + return executing; +} + +- (BOOL)isFinished { + return finished; +} + +- (void)setFinished:(BOOL)isFinished { + [self willChangeValueForKey:@"isFinished"]; + self->finished = isFinished; + [self didChangeValueForKey:@"isFinished"]; +} + +- (void)setExecuting:(BOOL)isExecuting { + [self willChangeValueForKey:@"isExecuting"]; + self->executing = isExecuting; + [self didChangeValueForKey:@"isExecuting"]; +} + +- (void)completeOperationWithPath:(NSString *)savedPath { + [self setExecuting:NO]; + [self setFinished:YES]; + getSavedPath(savedPath); +} + +- (void)start { + if ([self isCancelled]) { + [self setFinished:YES]; + return; + } + if (@available(iOS 14, *)) { + [self setExecuting:YES]; + [self.result.itemProvider + loadObjectOfClass:[UIImage class] + completionHandler:^(__kindof id _Nullable image, + NSError *_Nullable error) { + if ([image isKindOfClass:[UIImage class]]) { + __block UIImage *localImage = image; + PHAsset *originalAsset = + [FLTImagePickerPhotoAssetUtil getAssetFromPHPickerResult:self.result]; + + if (self.maxWidth != (id)[NSNull null] || self.maxHeight != (id)[NSNull null]) { + localImage = [FLTImagePickerImageUtil scaledImage:localImage + maxWidth:self.maxWidth + maxHeight:self.maxHeight + isMetadataAvailable:originalAsset != nil]; + } + __block NSString *savedPath; + if (!originalAsset) { + // Image picked without an original asset (e.g. User pick image without permission) + savedPath = + [FLTImagePickerPhotoAssetUtil saveImageWithPickerInfo:nil + image:localImage + imageQuality:self.desiredImageQuality]; + [self completeOperationWithPath:savedPath]; + } else { + [[PHImageManager defaultManager] + requestImageDataForAsset:originalAsset + options:nil + resultHandler:^( + NSData *_Nullable imageData, NSString *_Nullable dataUTI, + UIImageOrientation orientation, NSDictionary *_Nullable info) { + // maxWidth and maxHeight are used only for GIF images. + savedPath = [FLTImagePickerPhotoAssetUtil + saveImageWithOriginalImageData:imageData + image:localImage + maxWidth:self.maxWidth + maxHeight:self.maxHeight + imageQuality:self.desiredImageQuality]; + [self completeOperationWithPath:savedPath]; + }]; + } + } + }]; + } else { + [self setFinished:YES]; + } +} + +@end diff --git a/packages/image_picker/image_picker/lib/image_picker.dart b/packages/image_picker/image_picker/lib/image_picker.dart index 77c26d40346a..f4dee93ee1d6 100755 --- a/packages/image_picker/image_picker/lib/image_picker.dart +++ b/packages/image_picker/image_picker/lib/image_picker.dart @@ -54,6 +54,8 @@ class ImagePicker { /// /// In Android, the MainActivity can be destroyed for various reasons. If that happens, the result will be lost /// in this call. You can then call [getLostData] when your app relaunches to retrieve the lost data. + /// + /// See also [getMultiImage] to allow users to select multiple images at once. Future getImage({ required ImageSource source, double? maxWidth, @@ -70,6 +72,37 @@ class ImagePicker { ); } + /// Returns a [List] object wrapping the images that were picked. + /// + /// The returned [List] is intended to be used within a single APP session. Do not save the file path and use it across sessions. + /// + /// Where iOS supports HEIC images, Android 8 and below doesn't. Android 9 and above only support HEIC images if used + /// in addition to a size modification, of which the usage is explained below. + /// + /// This method is not supported in iOS versions lower than 14. + /// + /// If specified, the images will be at most `maxWidth` wide and + /// `maxHeight` tall. Otherwise the images will be returned at it's + /// original width and height. + /// The `imageQuality` argument modifies the quality of the images, ranging from 0-100 + /// where 100 is the original/max quality. If `imageQuality` is null, the images with + /// the original quality will be returned. Compression is only supported for certain + /// image types such as JPEG and on Android PNG and WebP, too. If compression is not supported for the image that is picked, + /// a warning message will be logged. + /// + /// See also [getImage] to allow users to only pick a single image. + Future?> getMultiImage({ + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) { + return platform.pickMultiImage( + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + ); + } + /// Returns a [PickedFile] object wrapping the video that was picked. /// /// The returned [PickedFile] is intended to be used within a single APP session. Do not save the file path and use it across sessions. diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 4dc7785111a4..b8aa9337a30c 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.0+4 +version: 0.8.1 environment: sdk: ">=2.12.0 <3.0.0" @@ -24,8 +24,8 @@ dependencies: flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 - image_picker_platform_interface: ^2.0.0 image_picker_for_web: ^2.0.0 + image_picker_platform_interface: ^2.1.0 dev_dependencies: flutter_test: diff --git a/packages/image_picker/image_picker/test/image_picker_test.dart b/packages/image_picker/image_picker/test/image_picker_test.dart index f56d47ff262b..d83b403d1d45 100644 --- a/packages/image_picker/image_picker/test/image_picker_test.dart +++ b/packages/image_picker/image_picker/test/image_picker_test.dart @@ -20,15 +20,6 @@ void main() { final picker = ImagePicker(); - setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - log.add(methodCall); - return ''; - }); - - log.clear(); - }); - test('ImagePicker platform instance overrides the actual platform used', () { final ImagePickerPlatform savedPlatform = ImagePickerPlatform.instance; @@ -38,312 +29,420 @@ void main() { ImagePickerPlatform.instance = savedPlatform; }); - group('#pickImage', () { - test('passes the image source argument correctly', () async { - await picker.getImage(source: ImageSource.camera); - await picker.getImage(source: ImageSource.gallery); - - expect( - log, - [ - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': null, - 'maxHeight': null, - 'imageQuality': null, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 1, - 'maxWidth': null, - 'maxHeight': null, - 'imageQuality': null, - 'cameraDevice': 0 - }), - ], - ); + group('#Single image/video', () { + setUp(() { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + return ''; + }); + + log.clear(); }); - test('passes the width and height arguments correctly', () async { - await picker.getImage(source: ImageSource.camera); - await picker.getImage( - source: ImageSource.camera, - maxWidth: 10.0, - ); - await picker.getImage( - source: ImageSource.camera, - maxHeight: 10.0, - ); - await picker.getImage( - source: ImageSource.camera, - maxWidth: 10.0, - maxHeight: 20.0, - ); - await picker.getImage( - source: ImageSource.camera, maxWidth: 10.0, imageQuality: 70); - await picker.getImage( - source: ImageSource.camera, maxHeight: 10.0, imageQuality: 70); - await picker.getImage( + group('#pickImage', () { + test('passes the image source argument correctly', () async { + await picker.getImage(source: ImageSource.camera); + await picker.getImage(source: ImageSource.gallery); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 1, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + ], + ); + }); + + test('passes the width and height arguments correctly', () async { + await picker.getImage(source: ImageSource.camera); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + ); + await picker.getImage( + source: ImageSource.camera, + maxHeight: 10.0, + ); + await picker.getImage( source: ImageSource.camera, maxWidth: 10.0, maxHeight: 20.0, - imageQuality: 70); - - expect( - log, - [ - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': null, - 'maxHeight': null, - 'imageQuality': null, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': 10.0, - 'maxHeight': null, - 'imageQuality': null, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': null, - 'maxHeight': 10.0, - 'imageQuality': null, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': 10.0, - 'maxHeight': 20.0, - 'imageQuality': null, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': 10.0, - 'maxHeight': null, - 'imageQuality': 70, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': null, - 'maxHeight': 10.0, - 'imageQuality': 70, - 'cameraDevice': 0 - }), - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': 10.0, - 'maxHeight': 20.0, - 'imageQuality': 70, - 'cameraDevice': 0 - }), - ], - ); - }); + ); + await picker.getImage( + source: ImageSource.camera, maxWidth: 10.0, imageQuality: 70); + await picker.getImage( + source: ImageSource.camera, maxHeight: 10.0, imageQuality: 70); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + maxHeight: 20.0, + imageQuality: 70); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + ], + ); + }); - test('does not accept a negative width or height argument', () { - expect( - picker.getImage(source: ImageSource.camera, maxWidth: -1.0), - throwsArgumentError, - ); + test('does not accept a negative width or height argument', () { + expect( + picker.getImage(source: ImageSource.camera, maxWidth: -1.0), + throwsArgumentError, + ); - expect( - picker.getImage(source: ImageSource.camera, maxHeight: -1.0), - throwsArgumentError, - ); - }); + expect( + picker.getImage(source: ImageSource.camera, maxHeight: -1.0), + throwsArgumentError, + ); + }); - test('handles a null image path response gracefully', () async { - channel.setMockMethodCallHandler((MethodCall methodCall) => null); + test('handles a null image path response gracefully', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) => null); - expect(await picker.getImage(source: ImageSource.gallery), isNull); - expect(await picker.getImage(source: ImageSource.camera), isNull); - }); + expect(await picker.getImage(source: ImageSource.gallery), isNull); + expect(await picker.getImage(source: ImageSource.camera), isNull); + }); - test('camera position defaults to back', () async { - await picker.getImage(source: ImageSource.camera); - - expect( - log, - [ - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': null, - 'maxHeight': null, - 'imageQuality': null, - 'cameraDevice': 0, - }), - ], - ); - }); + test('camera position defaults to back', () async { + await picker.getImage(source: ImageSource.camera); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0, + }), + ], + ); + }); - test('camera position can set to front', () async { - await picker.getImage( - source: ImageSource.camera, - preferredCameraDevice: CameraDevice.front); - - expect( - log, - [ - isMethodCall('pickImage', arguments: { - 'source': 0, - 'maxWidth': null, - 'maxHeight': null, - 'imageQuality': null, - 'cameraDevice': 1, - }), - ], - ); + test('camera position can set to front', () async { + await picker.getImage( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.front); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 1, + }), + ], + ); + }); }); - }); - group('#pickVideo', () { - test('passes the image source argument correctly', () async { - await picker.getVideo(source: ImageSource.camera); - await picker.getVideo(source: ImageSource.gallery); - - expect( - log, - [ - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'cameraDevice': 0, - 'maxDuration': null, - }), - isMethodCall('pickVideo', arguments: { - 'source': 1, - 'cameraDevice': 0, - 'maxDuration': null, - }), - ], - ); - }); + group('#pickVideo', () { + test('passes the image source argument correctly', () async { + await picker.getVideo(source: ImageSource.camera); + await picker.getVideo(source: ImageSource.gallery); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'cameraDevice': 0, + 'maxDuration': null, + }), + isMethodCall('pickVideo', arguments: { + 'source': 1, + 'cameraDevice': 0, + 'maxDuration': null, + }), + ], + ); + }); - test('passes the duration argument correctly', () async { - await picker.getVideo(source: ImageSource.camera); - await picker.getVideo( - source: ImageSource.camera, - maxDuration: const Duration(seconds: 10)); - await picker.getVideo( - source: ImageSource.camera, - maxDuration: const Duration(minutes: 1)); - await picker.getVideo( - source: ImageSource.camera, maxDuration: const Duration(hours: 1)); - expect( - log, - [ - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'maxDuration': null, - 'cameraDevice': 0, - }), - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'maxDuration': 10, - 'cameraDevice': 0, - }), - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'maxDuration': 60, - 'cameraDevice': 0, - }), - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'maxDuration': 3600, - 'cameraDevice': 0, - }), - ], - ); - }); + test('passes the duration argument correctly', () async { + await picker.getVideo(source: ImageSource.camera); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(seconds: 10)); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(minutes: 1)); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(hours: 1)); + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': null, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 10, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 60, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 3600, + 'cameraDevice': 0, + }), + ], + ); + }); - test('handles a null video path response gracefully', () async { - channel.setMockMethodCallHandler((MethodCall methodCall) => null); + test('handles a null video path response gracefully', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) => null); - expect(await picker.getVideo(source: ImageSource.gallery), isNull); - expect(await picker.getVideo(source: ImageSource.camera), isNull); - }); + expect(await picker.getVideo(source: ImageSource.gallery), isNull); + expect(await picker.getVideo(source: ImageSource.camera), isNull); + }); - test('camera position defaults to back', () async { - await picker.getVideo(source: ImageSource.camera); - - expect( - log, - [ - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'cameraDevice': 0, - 'maxDuration': null, - }), - ], - ); + test('camera position defaults to back', () async { + await picker.getVideo(source: ImageSource.camera); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'cameraDevice': 0, + 'maxDuration': null, + }), + ], + ); + }); + + test('camera position can set to front', () async { + await picker.getVideo( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.front); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': null, + 'cameraDevice': 1, + }), + ], + ); + }); }); - test('camera position can set to front', () async { - await picker.getVideo( - source: ImageSource.camera, - preferredCameraDevice: CameraDevice.front); - - expect( - log, - [ - isMethodCall('pickVideo', arguments: { - 'source': 0, - 'maxDuration': null, - 'cameraDevice': 1, - }), - ], - ); + group('#retrieveLostData', () { + test('retrieveLostData get success response', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'image', + 'path': '/example/path', + }; + }); + final LostData response = await picker.getLostData(); + expect(response.type, RetrieveType.image); + expect(response.file!.path, '/example/path'); + }); + + test('retrieveLostData get error response', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'video', + 'errorCode': 'test_error_code', + 'errorMessage': 'test_error_message', + }; + }); + final LostData response = await picker.getLostData(); + expect(response.type, RetrieveType.video); + expect(response.exception!.code, 'test_error_code'); + expect(response.exception!.message, 'test_error_message'); + }); + + test('retrieveLostData get null response', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return null; + }); + expect((await picker.getLostData()).isEmpty, true); + }); + + test('retrieveLostData get both path and error should throw', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'video', + 'errorCode': 'test_error_code', + 'errorMessage': 'test_error_message', + 'path': '/example/path', + }; + }); + expect(picker.getLostData(), throwsAssertionError); + }); }); }); - group('#retrieveLostData', () { - test('retrieveLostData get success response', () async { + group('Multi images', () { + setUp(() { channel.setMockMethodCallHandler((MethodCall methodCall) async { - return { - 'type': 'image', - 'path': '/example/path', - }; + log.add(methodCall); + return []; }); - final LostData response = await picker.getLostData(); - expect(response.type, RetrieveType.image); - expect(response.file!.path, '/example/path'); + log.clear(); }); - test('retrieveLostData get error response', () async { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return { - 'type': 'video', - 'errorCode': 'test_error_code', - 'errorMessage': 'test_error_message', - }; + group('#pickMultiImage', () { + test('passes the width and height arguments correctly', () async { + await picker.getMultiImage(); + await picker.getMultiImage( + maxWidth: 10.0, + ); + await picker.getMultiImage( + maxHeight: 10.0, + ); + await picker.getMultiImage( + maxWidth: 10.0, + maxHeight: 20.0, + ); + await picker.getMultiImage( + maxWidth: 10.0, + imageQuality: 70, + ); + await picker.getMultiImage( + maxHeight: 10.0, + imageQuality: 70, + ); + await picker.getMultiImage( + maxWidth: 10.0, maxHeight: 20.0, imageQuality: 70); + + expect( + log, + [ + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': 70, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': 70, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': 70, + }), + ], + ); }); - final LostData response = await picker.getLostData(); - expect(response.type, RetrieveType.video); - expect(response.exception!.code, 'test_error_code'); - expect(response.exception!.message, 'test_error_message'); - }); - test('retrieveLostData get null response', () async { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return null; + test('does not accept a negative width or height argument', () { + expect( + picker.getMultiImage(maxWidth: -1.0), + throwsArgumentError, + ); + + expect( + picker.getMultiImage(maxHeight: -1.0), + throwsArgumentError, + ); }); - expect((await picker.getLostData()).isEmpty, true); - }); - test('retrieveLostData get both path and error should throw', () async { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return { - 'type': 'video', - 'errorCode': 'test_error_code', - 'errorMessage': 'test_error_message', - 'path': '/example/path', - }; + test('handles a null image path response gracefully', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getMultiImage(), isNull); + expect(await picker.getMultiImage(), isNull); }); - expect(picker.getLostData(), throwsAssertionError); }); }); }); From 0232846289623fdb67ec0c9ef6efc20e8947c83d Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 22 Jun 2021 21:26:04 +0200 Subject: [PATCH 059/364] [camera] android-rework part 5: Android FPS range, resolution and sensor orientation features (#3799) --- .../flutter/plugins/camera/DartMessenger.java | 4 +- .../features/fpsrange/FpsRangeFeature.java | 87 +++++ .../resolution/ResolutionFeature.java | 176 +++++++++ .../features/resolution/ResolutionPreset.java | 15 + .../DeviceOrientationManager.java | 349 ++++++++++++++++++ .../SensorOrientationFeature.java | 105 ++++++ .../fpsrange/FpsRangeFeaturePixel4aTest.java | 30 ++ .../fpsrange/FpsRangeFeatureTest.java | 108 ++++++ .../resolution/ResolutionFeatureTest.java | 190 ++++++++++ .../DeviceOrientationManagerTest.java | 309 ++++++++++++++++ .../SensorOrientationFeatureTest.java | 126 +++++++ 11 files changed, 1497 insertions(+), 2 deletions(-) create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionPreset.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java index aaac1361eb3d..37bfbf294663 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java @@ -16,7 +16,7 @@ import java.util.HashMap; import java.util.Map; -class DartMessenger { +public class DartMessenger { @NonNull private final Handler handler; @Nullable private MethodChannel cameraChannel; @Nullable private MethodChannel deviceChannel; @@ -48,7 +48,7 @@ enum CameraEventType { this.handler = handler; } - void sendDeviceOrientationChangeEvent(PlatformChannel.DeviceOrientation orientation) { + public void sendDeviceOrientationChangeEvent(PlatformChannel.DeviceOrientation orientation) { assert (orientation != null); this.send( DeviceEventType.ORIENTATION_CHANGED, diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java new file mode 100644 index 000000000000..500f2aa28dc2 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java @@ -0,0 +1,87 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.fpsrange; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build; +import android.util.Range; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.features.CameraFeature; + +/** + * Controls the frames per seconds (FPS) range configuration on the {@link android.hardware.camera2} + * API. + */ +public class FpsRangeFeature extends CameraFeature> { + private static final Range MAX_PIXEL4A_RANGE = new Range<>(30, 30); + private Range currentSetting; + + /** + * Creates a new instance of the {@link FpsRangeFeature}. + * + * @param cameraProperties Collection of characteristics for the current camera device. + */ + public FpsRangeFeature(CameraProperties cameraProperties) { + super(cameraProperties); + + if (isPixel4A()) { + // HACK: There is a bug in the Pixel 4A where it cannot support 60fps modes + // even though they are reported as supported by + // `getControlAutoExposureAvailableTargetFpsRanges`. + // For max device compatibility we will keep FPS under 60 even if they report they are + // capable of achieving 60 fps. Highest working FPS is 30. + // https://issuetracker.google.com/issues/189237151 + currentSetting = MAX_PIXEL4A_RANGE; + } else { + Range[] ranges = cameraProperties.getControlAutoExposureAvailableTargetFpsRanges(); + + if (ranges != null) { + for (Range range : ranges) { + int upper = range.getUpper(); + + if (upper >= 10) { + if (currentSetting == null || upper > currentSetting.getUpper()) { + currentSetting = range; + } + } + } + } + } + } + + private boolean isPixel4A() { + return Build.BRAND.equals("google") && Build.MODEL.equals("Pixel 4a"); + } + + @Override + public String getDebugName() { + return "FpsRangeFeature"; + } + + @Override + public Range getValue() { + return currentSetting; + } + + @Override + public void setValue(Range value) { + this.currentSetting = value; + } + + // Always supported + @Override + public boolean checkIsSupported() { + return true; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + if (!checkIsSupported()) { + return; + } + + requestBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, currentSetting); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java new file mode 100644 index 000000000000..67763dde9be4 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java @@ -0,0 +1,176 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.resolution; + +import android.hardware.camera2.CaptureRequest; +import android.media.CamcorderProfile; +import android.util.Size; +import androidx.annotation.VisibleForTesting; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.features.CameraFeature; + +/** + * Controls the resolutions configuration on the {@link android.hardware.camera2} API. + * + *

The {@link ResolutionFeature} is responsible for converting the platform independent {@link + * ResolutionPreset} into a {@link android.media.CamcorderProfile} which contains all the properties + * required to configure the resolution using the {@link android.hardware.camera2} API. + */ +public class ResolutionFeature extends CameraFeature { + private Size captureSize; + private Size previewSize; + private CamcorderProfile recordingProfile; + private ResolutionPreset currentSetting; + private int cameraId; + + /** + * Creates a new instance of the {@link ResolutionFeature}. + * + * @param cameraProperties Collection of characteristics for the current camera device. + * @param resolutionPreset Platform agnostic enum containing resolution information. + * @param cameraName Camera identifier of the camera for which to configure the resolution. + */ + public ResolutionFeature( + CameraProperties cameraProperties, ResolutionPreset resolutionPreset, String cameraName) { + super(cameraProperties); + this.currentSetting = resolutionPreset; + try { + this.cameraId = Integer.parseInt(cameraName, 10); + } catch (NumberFormatException e) { + this.cameraId = -1; + return; + } + configureResolution(resolutionPreset, cameraId); + } + + /** + * Gets the {@link android.media.CamcorderProfile} containing the information to configure the + * resolution using the {@link android.hardware.camera2} API. + * + * @return Resolution information to configure the {@link android.hardware.camera2} API. + */ + public CamcorderProfile getRecordingProfile() { + return this.recordingProfile; + } + + /** + * Gets the optimal preview size based on the configured resolution. + * + * @return The optimal preview size. + */ + public Size getPreviewSize() { + return this.previewSize; + } + + /** + * Gets the optimal capture size based on the configured resolution. + * + * @return The optimal capture size. + */ + public Size getCaptureSize() { + return this.captureSize; + } + + @Override + public String getDebugName() { + return "ResolutionFeature"; + } + + @Override + public ResolutionPreset getValue() { + return currentSetting; + } + + @Override + public void setValue(ResolutionPreset value) { + this.currentSetting = value; + configureResolution(currentSetting, cameraId); + } + + @Override + public boolean checkIsSupported() { + return cameraId >= 0; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + // No-op: when setting a resolution there is no need to update the request builder. + } + + @VisibleForTesting + static Size computeBestPreviewSize(int cameraId, ResolutionPreset preset) { + if (preset.ordinal() > ResolutionPreset.high.ordinal()) { + preset = ResolutionPreset.high; + } + + CamcorderProfile profile = + getBestAvailableCamcorderProfileForResolutionPreset(cameraId, preset); + return new Size(profile.videoFrameWidth, profile.videoFrameHeight); + } + + /** + * Gets the best possible {@link android.media.CamcorderProfile} for the supplied {@link + * ResolutionPreset}. + * + * @param cameraId Camera identifier which indicates the device's camera for which to select a + * {@link android.media.CamcorderProfile}. + * @param preset The {@link ResolutionPreset} for which is to be translated to a {@link + * android.media.CamcorderProfile}. + * @return The best possible {@link android.media.CamcorderProfile} that matches the supplied + * {@link ResolutionPreset}. + */ + public static CamcorderProfile getBestAvailableCamcorderProfileForResolutionPreset( + int cameraId, ResolutionPreset preset) { + if (cameraId < 0) { + throw new AssertionError( + "getBestAvailableCamcorderProfileForResolutionPreset can only be used with valid (>=0) camera identifiers."); + } + + switch (preset) { + // All of these cases deliberately fall through to get the best available profile. + case max: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_HIGH)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_HIGH); + } + case ultraHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_2160P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_2160P); + } + case veryHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_1080P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_1080P); + } + case high: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_720P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_720P); + } + case medium: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_480P)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_480P); + } + case low: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_QVGA)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_QVGA); + } + default: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_LOW)) { + return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_LOW); + } else { + throw new IllegalArgumentException( + "No capture session available for current capture session."); + } + } + } + + private void configureResolution(ResolutionPreset resolutionPreset, int cameraId) { + if (!checkIsSupported()) { + return; + } + recordingProfile = + getBestAvailableCamcorderProfileForResolutionPreset(cameraId, resolutionPreset); + captureSize = new Size(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); + previewSize = computeBestPreviewSize(cameraId, resolutionPreset); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionPreset.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionPreset.java new file mode 100644 index 000000000000..359300305d40 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionPreset.java @@ -0,0 +1,15 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.resolution; + +// Mirrors camera.dart +public enum ResolutionPreset { + low, + medium, + high, + veryHigh, + ultraHigh, + max, +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java new file mode 100644 index 000000000000..2a04caad743a --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java @@ -0,0 +1,349 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.sensororientation; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.hardware.SensorManager; +import android.provider.Settings; +import android.view.Display; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.WindowManager; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import io.flutter.embedding.engine.systemchannels.PlatformChannel.DeviceOrientation; +import io.flutter.plugins.camera.DartMessenger; + +/** + * Support class to help to determine the media orientation based on the orientation of the device. + */ +public class DeviceOrientationManager { + + private static final IntentFilter orientationIntentFilter = + new IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED); + + private final Activity activity; + private final DartMessenger messenger; + private final boolean isFrontFacing; + private final int sensorOrientation; + private PlatformChannel.DeviceOrientation lastOrientation; + private OrientationEventListener orientationEventListener; + private BroadcastReceiver broadcastReceiver; + + /** Factory method to create a device orientation manager. */ + public static DeviceOrientationManager create( + @NonNull Activity activity, + @NonNull DartMessenger messenger, + boolean isFrontFacing, + int sensorOrientation) { + return new DeviceOrientationManager(activity, messenger, isFrontFacing, sensorOrientation); + } + + private DeviceOrientationManager( + @NonNull Activity activity, + @NonNull DartMessenger messenger, + boolean isFrontFacing, + int sensorOrientation) { + this.activity = activity; + this.messenger = messenger; + this.isFrontFacing = isFrontFacing; + this.sensorOrientation = sensorOrientation; + } + + /** + * Starts listening to the device's sensors or UI for orientation updates. + * + *

When orientation information is updated the new orientation is send to the client using the + * {@link DartMessenger}. This latest value can also be retrieved through the {@link + * #getMediaOrientation()} accessor. + * + *

If the device's ACCELEROMETER_ROTATION setting is enabled the {@link + * DeviceOrientationManager} will report orientation updates based on the sensor information. If + * the ACCELEROMETER_ROTATION is disabled the {@link DeviceOrientationManager} will fallback to + * the deliver orientation updates based on the UI orientation. + */ + public void start() { + startSensorListener(); + startUIListener(); + } + + /** Stops listening for orientation updates. */ + public void stop() { + stopSensorListener(); + stopUIListener(); + } + + /** + * Returns the last captured orientation in degrees based on sensor or UI information. + * + *

The orientation is returned in degrees and could be one of the following values: + * + *

    + *
  • 0: Indicates the device is currently in portrait. + *
  • 90: Indicates the device is currently in landscape left. + *
  • 180: Indicates the device is currently in portrait down. + *
  • 270: Indicates the device is currently in landscape right. + *
+ * + * @return The last captured orientation in degrees + */ + public int getMediaOrientation() { + return this.getMediaOrientation(this.lastOrientation); + } + + /** + * Returns the device's orientation in degrees based on the supplied {@link + * PlatformChannel.DeviceOrientation} value. + * + *

+ * + *

    + *
  • PORTRAIT_UP: converts to 0 degrees. + *
  • LANDSCAPE_LEFT: converts to 90 degrees. + *
  • PORTRAIT_DOWN: converts to 180 degrees. + *
  • LANDSCAPE_RIGHT: converts to 270 degrees. + *
+ * + * @param orientation The {@link PlatformChannel.DeviceOrientation} value that is to be converted + * into degrees. + * @return The device's orientation in degrees. + */ + public int getMediaOrientation(PlatformChannel.DeviceOrientation orientation) { + int angle = 0; + + // Fallback to device orientation when the orientation value is null + if (orientation == null) { + orientation = getUIOrientation(); + } + + switch (orientation) { + case PORTRAIT_UP: + angle = 0; + break; + case PORTRAIT_DOWN: + angle = 180; + break; + case LANDSCAPE_LEFT: + angle = 90; + break; + case LANDSCAPE_RIGHT: + angle = 270; + break; + } + + if (isFrontFacing) { + angle *= -1; + } + + return (angle + sensorOrientation + 360) % 360; + } + + private void startSensorListener() { + if (orientationEventListener != null) { + return; + } + orientationEventListener = + new OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) { + @Override + public void onOrientationChanged(int angle) { + handleSensorOrientationChange(angle); + } + }; + if (orientationEventListener.canDetectOrientation()) { + orientationEventListener.enable(); + } + } + + private void startUIListener() { + if (broadcastReceiver != null) { + return; + } + broadcastReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + handleUIOrientationChange(); + } + }; + activity.registerReceiver(broadcastReceiver, orientationIntentFilter); + broadcastReceiver.onReceive(activity, null); + } + + /** + * Handles orientation changes based on information from the device's sensors. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + * + * @param angle of the current orientation. + */ + @VisibleForTesting + void handleSensorOrientationChange(int angle) { + if (!isAccelerometerRotationLocked()) { + PlatformChannel.DeviceOrientation orientation = calculateSensorOrientation(angle); + lastOrientation = handleOrientationChange(orientation, lastOrientation, messenger); + } + } + + /** + * Handles orientation changes based on change events triggered by the OrientationIntentFilter. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + */ + @VisibleForTesting + void handleUIOrientationChange() { + if (isAccelerometerRotationLocked()) { + PlatformChannel.DeviceOrientation orientation = getUIOrientation(); + lastOrientation = handleOrientationChange(orientation, lastOrientation, messenger); + } + } + + /** + * Handles orientation changes coming from either the device's sensors or the + * OrientationIntentFilter. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + */ + @VisibleForTesting + static DeviceOrientation handleOrientationChange( + DeviceOrientation newOrientation, + DeviceOrientation previousOrientation, + DartMessenger messenger) { + if (!newOrientation.equals(previousOrientation)) { + messenger.sendDeviceOrientationChangeEvent(newOrientation); + } + + return newOrientation; + } + + private void stopSensorListener() { + if (orientationEventListener == null) { + return; + } + orientationEventListener.disable(); + orientationEventListener = null; + } + + private void stopUIListener() { + if (broadcastReceiver == null) { + return; + } + activity.unregisterReceiver(broadcastReceiver); + broadcastReceiver = null; + } + + private boolean isAccelerometerRotationLocked() { + return android.provider.Settings.System.getInt( + activity.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) + != 1; + } + + /** + * Gets the current user interface orientation. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + * + * @return The current user interface orientation. + */ + @VisibleForTesting + PlatformChannel.DeviceOrientation getUIOrientation() { + final int rotation = getDisplay().getRotation(); + final int orientation = activity.getResources().getConfiguration().orientation; + + switch (orientation) { + case Configuration.ORIENTATION_PORTRAIT: + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) { + return PlatformChannel.DeviceOrientation.PORTRAIT_UP; + } else { + return PlatformChannel.DeviceOrientation.PORTRAIT_DOWN; + } + case Configuration.ORIENTATION_LANDSCAPE: + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) { + return PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT; + } else { + return PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT; + } + default: + return PlatformChannel.DeviceOrientation.PORTRAIT_UP; + } + } + + /** + * Calculates the sensor orientation based on the supplied angle. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + * + * @param angle Orientation angle. + * @return The sensor orientation based on the supplied angle. + */ + @VisibleForTesting + PlatformChannel.DeviceOrientation calculateSensorOrientation(int angle) { + final int tolerance = 45; + angle += tolerance; + + // Orientation is 0 in the default orientation mode. This is portrait-mode for phones + // and landscape for tablets. We have to compensate for this by calculating the default + // orientation, and apply an offset accordingly. + int defaultDeviceOrientation = getDeviceDefaultOrientation(); + if (defaultDeviceOrientation == Configuration.ORIENTATION_LANDSCAPE) { + angle += 90; + } + // Determine the orientation + angle = angle % 360; + return new PlatformChannel.DeviceOrientation[] { + PlatformChannel.DeviceOrientation.PORTRAIT_UP, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT, + PlatformChannel.DeviceOrientation.PORTRAIT_DOWN, + PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT, + } + [angle / 90]; + } + + /** + * Gets the default orientation of the device. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + * + * @return The default orientation of the device. + */ + @VisibleForTesting + int getDeviceDefaultOrientation() { + Configuration config = activity.getResources().getConfiguration(); + int rotation = getDisplay().getRotation(); + if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) + && config.orientation == Configuration.ORIENTATION_LANDSCAPE) + || ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) + && config.orientation == Configuration.ORIENTATION_PORTRAIT)) { + return Configuration.ORIENTATION_LANDSCAPE; + } else { + return Configuration.ORIENTATION_PORTRAIT; + } + } + + /** + * Gets an instance of the Android {@link android.view.Display}. + * + *

This method is visible for testing purposes only and should never be used outside this + * class. + * + * @return An instance of the Android {@link android.view.Display}. + */ + @SuppressWarnings("deprecation") + @VisibleForTesting + Display getDisplay() { + return ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java new file mode 100644 index 000000000000..9e316f741805 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java @@ -0,0 +1,105 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.sensororientation; + +import android.app.Activity; +import android.hardware.camera2.CameraMetadata; +import android.hardware.camera2.CaptureRequest; +import androidx.annotation.NonNull; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.CameraFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; + +/** Provides access to the sensor orientation of the camera devices. */ +public class SensorOrientationFeature extends CameraFeature { + private Integer currentSetting = 0; + private final DeviceOrientationManager deviceOrientationListener; + private PlatformChannel.DeviceOrientation lockedCaptureOrientation; + + /** + * Creates a new instance of the {@link ResolutionFeature}. + * + * @param cameraProperties Collection of characteristics for the current camera device. + * @param activity Current Android {@link android.app.Activity}, used to detect UI orientation + * changes. + * @param dartMessenger Instance of a {@link DartMessenger} used to communicate orientation + * updates back to the client. + */ + public SensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger) { + super(cameraProperties); + setValue(cameraProperties.getSensorOrientation()); + + boolean isFrontFacing = cameraProperties.getLensFacing() == CameraMetadata.LENS_FACING_FRONT; + deviceOrientationListener = + DeviceOrientationManager.create(activity, dartMessenger, isFrontFacing, currentSetting); + deviceOrientationListener.start(); + } + + @Override + public String getDebugName() { + return "SensorOrientationFeature"; + } + + @Override + public Integer getValue() { + return currentSetting; + } + + @Override + public void setValue(Integer value) { + this.currentSetting = value; + } + + @Override + public boolean checkIsSupported() { + return true; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + // Noop: when setting the sensor orientation there is no need to update the request builder. + } + + /** + * Gets the instance of the {@link DeviceOrientationManager} used to detect orientation changes. + * + * @return The instance of the {@link DeviceOrientationManager}. + */ + public DeviceOrientationManager getDeviceOrientationManager() { + return this.deviceOrientationListener; + } + + /** + * Lock the capture orientation, indicating that the device orientation should not influence the + * capture orientation. + * + * @param orientation The orientation in which to lock the capture orientation. + */ + public void lockCaptureOrientation(PlatformChannel.DeviceOrientation orientation) { + this.lockedCaptureOrientation = orientation; + } + + /** + * Unlock the capture orientation, indicating that the device orientation should be used to + * configure the capture orientation. + */ + public void unlockCaptureOrientation() { + this.lockedCaptureOrientation = null; + } + + /** + * Gets the configured locked capture orientation. + * + * @return The configured locked capture orientation. + */ + public PlatformChannel.DeviceOrientation getLockedCaptureOrientation() { + return this.lockedCaptureOrientation; + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java new file mode 100644 index 000000000000..7b6e70fff5b2 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java @@ -0,0 +1,30 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.fpsrange; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +import android.os.Build; +import android.util.Range; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class FpsRangeFeaturePixel4aTest { + @Test + public void ctor_should_initialize_fps_range_with_30_when_device_is_pixel_4a() { + TestUtils.setFinalStatic(Build.class, "BRAND", "google"); + TestUtils.setFinalStatic(Build.class, "MODEL", "Pixel 4a"); + + FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mock(CameraProperties.class)); + Range range = fpsRangeFeature.getValue(); + assertEquals(30, (int) range.getLower()); + assertEquals(30, (int) range.getUpper()); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java new file mode 100644 index 000000000000..77937b5e87c6 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java @@ -0,0 +1,108 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.fpsrange; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build; +import android.util.Range; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class FpsRangeFeatureTest { + @Before + public void before() { + TestUtils.setFinalStatic(Build.class, "BRAND", "Test Brand"); + TestUtils.setFinalStatic(Build.class, "MODEL", "Test Model"); + } + + @After + public void after() { + TestUtils.setFinalStatic(Build.class, "BRAND", null); + TestUtils.setFinalStatic(Build.class, "MODEL", null); + } + + @Test + public void ctor_should_initialize_fps_range_with_highest_upper_value_from_range_array() { + FpsRangeFeature fpsRangeFeature = createTestInstance(); + assertEquals(13, (int) fpsRangeFeature.getValue().getUpper()); + } + + @Test + public void getDebugName_should_return_the_name_of_the_feature() { + FpsRangeFeature fpsRangeFeature = createTestInstance(); + assertEquals("FpsRangeFeature", fpsRangeFeature.getDebugName()); + } + + @Test + public void getValue_should_return_highest_upper_range_if_not_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FpsRangeFeature fpsRangeFeature = createTestInstance(); + + assertEquals(13, (int) fpsRangeFeature.getValue().getUpper()); + } + + @Test + public void getValue_should_echo_the_set_value() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mockCameraProperties); + @SuppressWarnings("unchecked") + Range expectedValue = mock(Range.class); + + fpsRangeFeature.setValue(expectedValue); + Range actualValue = fpsRangeFeature.getValue(); + + assertEquals(expectedValue, actualValue); + } + + @Test + public void checkIsSupported_should_return_true() { + FpsRangeFeature fpsRangeFeature = createTestInstance(); + assertTrue(fpsRangeFeature.checkIsSupported()); + } + + @Test + @SuppressWarnings("unchecked") + public void updateBuilder_should_set_ae_target_fps_range() { + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + FpsRangeFeature fpsRangeFeature = createTestInstance(); + + fpsRangeFeature.updateBuilder(mockBuilder); + + verify(mockBuilder).set(eq(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE), any(Range.class)); + } + + private static FpsRangeFeature createTestInstance() { + @SuppressWarnings("unchecked") + Range rangeOne = mock(Range.class); + @SuppressWarnings("unchecked") + Range rangeTwo = mock(Range.class); + @SuppressWarnings("unchecked") + Range rangeThree = mock(Range.class); + + when(rangeOne.getUpper()).thenReturn(11); + when(rangeTwo.getUpper()).thenReturn(12); + when(rangeThree.getUpper()).thenReturn(13); + + @SuppressWarnings("unchecked") + Range[] ranges = new Range[] {rangeOne, rangeTwo, rangeThree}; + + CameraProperties cameraProperties = mock(CameraProperties.class); + + when(cameraProperties.getControlAutoExposureAvailableTargetFpsRanges()).thenReturn(ranges); + + return new FpsRangeFeature(cameraProperties); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java new file mode 100644 index 000000000000..bb9cb61e1508 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java @@ -0,0 +1,190 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.resolution; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import android.media.CamcorderProfile; +import io.flutter.plugins.camera.CameraProperties; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class ResolutionFeatureTest { + private static final String cameraName = "1"; + private CamcorderProfile mockProfileLow; + private MockedStatic mockedStaticProfile; + + @Before + public void before() { + mockedStaticProfile = mockStatic(CamcorderProfile.class); + mockProfileLow = mock(CamcorderProfile.class); + CamcorderProfile mockProfile = mock(CamcorderProfile.class); + + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(true); + + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(mockProfileLow); + } + + @After + public void after() { + mockedStaticProfile.reset(); + mockedStaticProfile.close(); + } + + @Test + public void getDebugName_should_return_the_name_of_the_feature() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + assertEquals("ResolutionFeature", resolutionFeature.getDebugName()); + } + + @Test + public void getValue_should_return_initial_value_when_not_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + assertEquals(ResolutionPreset.max, resolutionFeature.getValue()); + } + + @Test + public void getValue_should_echo_setValue() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + resolutionFeature.setValue(ResolutionPreset.high); + + assertEquals(ResolutionPreset.high, resolutionFeature.getValue()); + } + + @Test + public void checkIsSupport_returns_true() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + assertTrue(resolutionFeature.checkIsSupported()); + } + + @Test + public void getBestAvailableCamcorderProfileForResolutionPreset_should_fall_through() { + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(true); + + assertEquals( + mockProfileLow, + ResolutionFeature.getBestAvailableCamcorderProfileForResolutionPreset( + 1, ResolutionPreset.max)); + } + + @Test + public void computeBestPreviewSize_should_use_720P_when_resolution_preset_max() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.max); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Test + public void computeBestPreviewSize_should_use_720P_when_resolution_preset_ultraHigh() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.ultraHigh); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Test + public void computeBestPreviewSize_should_use_720P_when_resolution_preset_veryHigh() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.veryHigh); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Test + public void computeBestPreviewSize_should_use_720P_when_resolution_preset_high() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.high); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Test + public void computeBestPreviewSize_should_use_480P_when_resolution_preset_medium() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.medium); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_480P)); + } + + @Test + public void computeBestPreviewSize_should_use_QVGA_when_resolution_preset_low() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.low); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_QVGA)); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java new file mode 100644 index 000000000000..6e8d04d20e99 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java @@ -0,0 +1,309 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.sensororientation; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.provider.Settings; +import android.view.Display; +import android.view.Surface; +import android.view.WindowManager; +import io.flutter.embedding.engine.systemchannels.PlatformChannel.DeviceOrientation; +import io.flutter.plugins.camera.DartMessenger; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class DeviceOrientationManagerTest { + private Activity mockActivity; + private DartMessenger mockDartMessenger; + private WindowManager mockWindowManager; + private Display mockDisplay; + private DeviceOrientationManager deviceOrientationManager; + + @Before + public void before() { + mockActivity = mock(Activity.class); + mockDartMessenger = mock(DartMessenger.class); + mockDisplay = mock(Display.class); + mockWindowManager = mock(WindowManager.class); + + when(mockActivity.getSystemService(Context.WINDOW_SERVICE)).thenReturn(mockWindowManager); + when(mockWindowManager.getDefaultDisplay()).thenReturn(mockDisplay); + + deviceOrientationManager = + DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 0); + } + + @Test + public void getMediaOrientation_when_natural_screen_orientation_equals_portrait_up() { + int degreesPortraitUp = + deviceOrientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitDown = + deviceOrientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_DOWN); + int degreesLandscapeLeft = + deviceOrientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_LEFT); + int degreesLandscapeRight = + deviceOrientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_RIGHT); + + assertEquals(0, degreesPortraitUp); + assertEquals(90, degreesLandscapeLeft); + assertEquals(180, degreesPortraitDown); + assertEquals(270, degreesLandscapeRight); + } + + @Test + public void getMediaOrientation_when_natural_screen_orientation_equals_landscape_left() { + DeviceOrientationManager orientationManager = + DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 90); + + int degreesPortraitUp = orientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitDown = + orientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_DOWN); + int degreesLandscapeLeft = + orientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_LEFT); + int degreesLandscapeRight = + orientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_RIGHT); + + assertEquals(90, degreesPortraitUp); + assertEquals(180, degreesLandscapeLeft); + assertEquals(270, degreesPortraitDown); + assertEquals(0, degreesLandscapeRight); + } + + @Test + public void getMediaOrientation_should_fallback_to_sensor_orientation_when_orientation_is_null() { + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + + int degrees = deviceOrientationManager.getMediaOrientation(null); + + assertEquals(90, degrees); + } + + @Test + public void handleSensorOrientationChange_should_send_message_when_sensor_access_is_allowed() { + try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { + mockedSystem + .when( + () -> + Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) + .thenReturn(1); + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + + deviceOrientationManager.handleSensorOrientationChange(90); + } + + verify(mockDartMessenger, times(1)) + .sendDeviceOrientationChangeEvent(DeviceOrientation.LANDSCAPE_LEFT); + } + + @Test + public void + handleSensorOrientationChange_should_send_message_when_sensor_access_is_not_allowed() { + try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { + mockedSystem + .when( + () -> + Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) + .thenReturn(0); + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + + deviceOrientationManager.handleSensorOrientationChange(90); + } + + verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); + } + + @Test + public void handleUIOrientationChange_should_send_message_when_sensor_access_is_allowed() { + try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { + mockedSystem + .when( + () -> + Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) + .thenReturn(0); + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + + deviceOrientationManager.handleUIOrientationChange(); + } + + verify(mockDartMessenger, times(1)) + .sendDeviceOrientationChangeEvent(DeviceOrientation.LANDSCAPE_LEFT); + } + + @Test + public void handleUIOrientationChange_should_send_message_when_sensor_access_is_not_allowed() { + try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { + mockedSystem + .when( + () -> + Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) + .thenReturn(1); + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + + deviceOrientationManager.handleUIOrientationChange(); + } + + verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); + } + + @Test + public void handleOrientationChange_should_send_message_when_orientation_is_updated() { + DeviceOrientation previousOrientation = DeviceOrientation.PORTRAIT_UP; + DeviceOrientation newOrientation = DeviceOrientation.LANDSCAPE_LEFT; + + DeviceOrientation orientation = + DeviceOrientationManager.handleOrientationChange( + newOrientation, previousOrientation, mockDartMessenger); + + verify(mockDartMessenger, times(1)).sendDeviceOrientationChangeEvent(newOrientation); + assertEquals(newOrientation, orientation); + } + + @Test + public void handleOrientationChange_should_not_send_message_when_orientation_is_not_updated() { + DeviceOrientation previousOrientation = DeviceOrientation.PORTRAIT_UP; + DeviceOrientation newOrientation = DeviceOrientation.PORTRAIT_UP; + + DeviceOrientation orientation = + DeviceOrientationManager.handleOrientationChange( + newOrientation, previousOrientation, mockDartMessenger); + + verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); + assertEquals(newOrientation, orientation); + } + + @Test + public void getUIOrientation() { + // Orientation portrait and rotation of 0 should translate to "PORTRAIT_UP". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + DeviceOrientation uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_UP, uiOrientation); + + // Orientation portrait and rotation of 90 should translate to "PORTRAIT_UP". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_90); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_UP, uiOrientation); + + // Orientation portrait and rotation of 180 should translate to "PORTRAIT_DOWN". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_180); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_DOWN, uiOrientation); + + // Orientation portrait and rotation of 270 should translate to "PORTRAIT_DOWN". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_270); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_DOWN, uiOrientation); + + // Orientation landscape and rotation of 0 should translate to "LANDSCAPE_LEFT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_LEFT, uiOrientation); + + // Orientation landscape and rotation of 90 should translate to "LANDSCAPE_LEFT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_90); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_LEFT, uiOrientation); + + // Orientation landscape and rotation of 180 should translate to "LANDSCAPE_RIGHT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_180); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_RIGHT, uiOrientation); + + // Orientation landscape and rotation of 270 should translate to "LANDSCAPE_RIGHT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_270); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_RIGHT, uiOrientation); + + // Orientation undefined should default to "PORTRAIT_UP". + setUpUIOrientationMocks(Configuration.ORIENTATION_UNDEFINED, Surface.ROTATION_0); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_UP, uiOrientation); + } + + @Test + public void getDeviceDefaultOrientation() { + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + int orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_180); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_90); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_270); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_180); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_90); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_270); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + } + + @Test + public void calculateSensorOrientation() { + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + DeviceOrientation orientation = deviceOrientationManager.calculateSensorOrientation(0); + assertEquals(DeviceOrientation.PORTRAIT_UP, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + orientation = deviceOrientationManager.calculateSensorOrientation(90); + assertEquals(DeviceOrientation.LANDSCAPE_LEFT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + orientation = deviceOrientationManager.calculateSensorOrientation(180); + assertEquals(DeviceOrientation.PORTRAIT_DOWN, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + orientation = deviceOrientationManager.calculateSensorOrientation(270); + assertEquals(DeviceOrientation.LANDSCAPE_RIGHT, orientation); + } + + private void setUpUIOrientationMocks(int orientation, int rotation) { + Resources mockResources = mock(Resources.class); + Configuration mockConfiguration = mock(Configuration.class); + + when(mockDisplay.getRotation()).thenReturn(rotation); + + mockConfiguration.orientation = orientation; + when(mockActivity.getResources()).thenReturn(mockResources); + when(mockResources.getConfiguration()).thenReturn(mockConfiguration); + } + + @Test + public void getDisplayTest() { + Display display = deviceOrientationManager.getDisplay(); + + assertEquals(mockDisplay, display); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java new file mode 100644 index 000000000000..ce2bb7bb2670 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java @@ -0,0 +1,126 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.sensororientation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.hardware.camera2.CameraMetadata; +import io.flutter.embedding.engine.systemchannels.PlatformChannel.DeviceOrientation; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class SensorOrientationFeatureTest { + private MockedStatic mockedStaticDeviceOrientationManager; + private Activity mockActivity; + private CameraProperties mockCameraProperties; + private DartMessenger mockDartMessenger; + private DeviceOrientationManager mockDeviceOrientationManager; + + @Before + public void before() { + mockedStaticDeviceOrientationManager = mockStatic(DeviceOrientationManager.class); + mockActivity = mock(Activity.class); + mockCameraProperties = mock(CameraProperties.class); + mockDartMessenger = mock(DartMessenger.class); + mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + + when(mockCameraProperties.getSensorOrientation()).thenReturn(0); + when(mockCameraProperties.getLensFacing()).thenReturn(CameraMetadata.LENS_FACING_BACK); + + mockedStaticDeviceOrientationManager + .when(() -> DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 0)) + .thenReturn(mockDeviceOrientationManager); + } + + @After + public void after() { + mockedStaticDeviceOrientationManager.close(); + } + + @Test + public void ctor_should_start_device_orientation_manager() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + verify(mockDeviceOrientationManager, times(1)).start(); + } + + @Test + public void getDebugName_should_return_the_name_of_the_feature() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertEquals("SensorOrientationFeature", sensorOrientationFeature.getDebugName()); + } + + @Test + public void getValue_should_return_null_if_not_set() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertEquals(0, (int) sensorOrientationFeature.getValue()); + } + + @Test + public void getValue_should_echo_setValue() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + sensorOrientationFeature.setValue(90); + + assertEquals(90, (int) sensorOrientationFeature.getValue()); + } + + @Test + public void checkIsSupport_returns_true() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertTrue(sensorOrientationFeature.checkIsSupported()); + } + + @Test + public void + getDeviceOrientationManager_should_return_initialized_DartOrientationManager_instance() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertEquals( + mockDeviceOrientationManager, sensorOrientationFeature.getDeviceOrientationManager()); + } + + @Test + public void lockCaptureOrientation_should_lock_to_specified_orientation() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + sensorOrientationFeature.lockCaptureOrientation(DeviceOrientation.PORTRAIT_DOWN); + + assertEquals( + DeviceOrientation.PORTRAIT_DOWN, sensorOrientationFeature.getLockedCaptureOrientation()); + } + + @Test + public void unlockCaptureOrientation_should_set_lock_to_null() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + sensorOrientationFeature.unlockCaptureOrientation(); + + assertNull(sensorOrientationFeature.getLockedCaptureOrientation()); + } +} From d91b0087d48d21959a0243faaf447ad87ca14cbb Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Tue, 22 Jun 2021 21:31:09 +0200 Subject: [PATCH 060/364] [camera] android-rework part 7: Android noise reduction feature (#4052) --- .../camera/features/flash/FlashMode.java | 9 ++ .../noisereduction/NoiseReductionFeature.java | 94 +++++++++++ .../noisereduction/NoiseReductionMode.java | 41 +++++ .../NoiseReductionFeatureTest.java | 151 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionMode.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java index d4a5ee0ab12f..788c768e0b3c 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/flash/FlashMode.java @@ -17,6 +17,15 @@ public enum FlashMode { this.strValue = strValue; } + /** + * Tries to convert the supplied string into a {@see FlashMode} enum value. + * + *

When the supplied string doesn't match a valid {@see FlashMode} enum value, null is + * returned. + * + * @param modeStr String value to convert into an {@see FlashMode} enum value. + * @return Matching {@see FlashMode} enum value, or null if no match is found. + */ public static FlashMode getValueForString(String modeStr) { for (FlashMode value : values()) { if (value.strValue.equals(modeStr)) return value; diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java new file mode 100644 index 000000000000..847a817641ab --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java @@ -0,0 +1,94 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.noisereduction; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.util.Log; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.features.CameraFeature; +import java.util.HashMap; + +/** + * This can either be enabled or disabled. Only full capability devices can set this to off. Legacy + * and full support the fast mode. + * https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics#NOISE_REDUCTION_AVAILABLE_NOISE_REDUCTION_MODES + */ +public class NoiseReductionFeature extends CameraFeature { + private NoiseReductionMode currentSetting = NoiseReductionMode.fast; + + private static final HashMap NOISE_REDUCTION_MODES = new HashMap<>(); + + static { + NOISE_REDUCTION_MODES.put(NoiseReductionMode.off, CaptureRequest.NOISE_REDUCTION_MODE_OFF); + NOISE_REDUCTION_MODES.put(NoiseReductionMode.fast, CaptureRequest.NOISE_REDUCTION_MODE_FAST); + NOISE_REDUCTION_MODES.put( + NoiseReductionMode.highQuality, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY); + if (VERSION.SDK_INT >= VERSION_CODES.M) { + NOISE_REDUCTION_MODES.put( + NoiseReductionMode.minimal, CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL); + NOISE_REDUCTION_MODES.put( + NoiseReductionMode.zeroShutterLag, CaptureRequest.NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG); + } + } + + /** + * Creates a new instance of the {@link NoiseReductionFeature}. + * + * @param cameraProperties Collection of the characteristics for the current camera device. + */ + public NoiseReductionFeature(CameraProperties cameraProperties) { + super(cameraProperties); + } + + @Override + public String getDebugName() { + return "NoiseReductionFeature"; + } + + @Override + public NoiseReductionMode getValue() { + return currentSetting; + } + + @Override + public void setValue(NoiseReductionMode value) { + this.currentSetting = value; + } + + @Override + public boolean checkIsSupported() { + /* + * Available settings: public static final int NOISE_REDUCTION_MODE_FAST = 1; public static + * final int NOISE_REDUCTION_MODE_HIGH_QUALITY = 2; public static final int + * NOISE_REDUCTION_MODE_MINIMAL = 3; public static final int NOISE_REDUCTION_MODE_OFF = 0; + * public static final int NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG = 4; + * + *

Full-capability camera devices will always support OFF and FAST. Camera devices that + * support YUV_REPROCESSING or PRIVATE_REPROCESSING will support ZERO_SHUTTER_LAG. + * Legacy-capability camera devices will only support FAST mode. + */ + + // Can be null on some devices. + int[] modes = cameraProperties.getAvailableNoiseReductionModes(); + + /// If there's at least one mode available then we are supported. + return modes != null && modes.length > 0; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + if (!checkIsSupported()) { + return; + } + + Log.i("Camera", "updateNoiseReduction | currentSetting: " + currentSetting); + + // Always use fast mode. + requestBuilder.set( + CaptureRequest.NOISE_REDUCTION_MODE, NOISE_REDUCTION_MODES.get(currentSetting)); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionMode.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionMode.java new file mode 100644 index 000000000000..425a458e2a2b --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionMode.java @@ -0,0 +1,41 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.noisereduction; + +/** Only supports fast mode for now. */ +public enum NoiseReductionMode { + off("off"), + fast("fast"), + highQuality("highQuality"), + minimal("minimal"), + zeroShutterLag("zeroShutterLag"); + + private final String strValue; + + NoiseReductionMode(String strValue) { + this.strValue = strValue; + } + + /** + * Tries to convert the supplied string into a {@see NoiseReductionMode} enum value. + * + *

When the supplied string doesn't match a valid {@see NoiseReductionMode} enum value, null is + * returned. + * + * @param modeStr String value to convert into an {@see NoiseReductionMode} enum value. + * @return Matching {@see NoiseReductionMode} enum value, or null if no match is found. + */ + public static NoiseReductionMode getValueForString(String modeStr) { + for (NoiseReductionMode value : values()) { + if (value.strValue.equals(modeStr)) return value; + } + return null; + } + + @Override + public String toString() { + return strValue; + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java new file mode 100644 index 000000000000..eb1a639a2ac3 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java @@ -0,0 +1,151 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.noisereduction; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build.VERSION; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class NoiseReductionFeatureTest { + @Before + public void before() { + // Make sure the VERSION.SDK_INT field returns 23, to allow using all available + // noise reduction modes in tests. + TestUtils.setFinalStatic(VERSION.class, "SDK_INT", 23); + } + + @After + public void after() { + // Make sure we reset the VERSION.SDK_INT field to it's original value. + TestUtils.setFinalStatic(VERSION.class, "SDK_INT", 0); + } + + @Test + public void getDebugName_should_return_the_name_of_the_feature() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + assertEquals("NoiseReductionFeature", noiseReductionFeature.getDebugName()); + } + + @Test + public void getValue_should_return_fast_if_not_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + assertEquals(NoiseReductionMode.fast, noiseReductionFeature.getValue()); + } + + @Test + public void getValue_should_echo_the_set_value() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + NoiseReductionMode expectedValue = NoiseReductionMode.fast; + + noiseReductionFeature.setValue(expectedValue); + NoiseReductionMode actualValue = noiseReductionFeature.getValue(); + + assertEquals(expectedValue, actualValue); + } + + @Test + public void checkIsSupported_should_return_false_when_available_noise_reduction_modes_is_null() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(null); + + assertFalse(noiseReductionFeature.checkIsSupported()); + } + + @Test + public void + checkIsSupported_should_return_false_when_available_noise_reduction_modes_returns_an_empty_array() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {}); + + assertFalse(noiseReductionFeature.checkIsSupported()); + } + + @Test + public void + checkIsSupported_should_return_true_when_available_noise_reduction_modes_returns_at_least_one_item() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {1}); + + assertTrue(noiseReductionFeature.checkIsSupported()); + } + + @Test + public void updateBuilder_should_return_when_checkIsSupported_is_false() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {}); + + noiseReductionFeature.updateBuilder(mockBuilder); + + verify(mockBuilder, never()).set(any(), any()); + } + + @Test + public void updateBuilder_should_set_noise_reduction_mode_off_when_off() { + testUpdateBuilderWith(NoiseReductionMode.off, CaptureRequest.NOISE_REDUCTION_MODE_OFF); + } + + @Test + public void updateBuilder_should_set_noise_reduction_mode_fast_when_fast() { + testUpdateBuilderWith(NoiseReductionMode.fast, CaptureRequest.NOISE_REDUCTION_MODE_FAST); + } + + @Test + public void updateBuilder_should_set_noise_reduction_mode_high_quality_when_high_quality() { + testUpdateBuilderWith( + NoiseReductionMode.highQuality, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY); + } + + @Test + public void updateBuilder_should_set_noise_reduction_mode_minimal_when_minimal() { + testUpdateBuilderWith(NoiseReductionMode.minimal, CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL); + } + + @Test + public void + updateBuilder_should_set_noise_reduction_mode_zero_shutter_lag_when_zero_shutter_lag() { + testUpdateBuilderWith( + NoiseReductionMode.zeroShutterLag, CaptureRequest.NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG); + } + + private static void testUpdateBuilderWith(NoiseReductionMode mode, int expectedResult) { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {1}); + + noiseReductionFeature.setValue(mode); + noiseReductionFeature.updateBuilder(mockBuilder); + verify(mockBuilder, times(1)).set(CaptureRequest.NOISE_REDUCTION_MODE, expectedResult); + } +} From 13e5b5b34c151aaa29cccd14ec578914dd7ac221 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 22 Jun 2021 13:31:30 -0700 Subject: [PATCH 061/364] Add a link to the new wiki page about plugin and package PRs (#4074) --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5fc990033ba..ac66886c1ff9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,9 @@ Additional resources specific to the plugins repository: As explained in the Flutter guide, [**PRs needs tests**](https://github.com/flutter/flutter/wiki/Tree-hygiene#tests), so this is critical to read before submitting a PR. +- [Contributing to Plugins and Packages](https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages), + for more information about how to make PRs for this repository, especially when + changing federated plugins. ## Important note From b921089cf93ae4728ad17ee075d5f3ede6785a08 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 22 Jun 2021 13:32:03 -0700 Subject: [PATCH 062/364] [flutter_plugin_tools] Add a new base command for looping over packages (#4067) Most of our commands are generally of the form: ``` for (each plugin as defined by the tool flags) check some things for success or failure print a summary all of the failing things exit non-zero if anything failed ``` Currently all that logic not consistent, having been at various points copied and pasted around, modified, in some cases rewritten. There's unnecessary boilerplate in each new command, and there's unnecessary variation that makes it harder both to maintain the tool, and to consume the test output: - There's no standard format for separating each plugin's run to search within a log - There's no standard format for the summary at the end - In some cases commands have been written to ToolExit on failure, which means we don't actually get the rest of the runs This makes a new base class for commands that follow this structure to use, with shared code for all the common bits. This makes it harder to accidentally write new commands incorrectly, easier to maintain the code, and lets us standardize output so that searching within large logs will be easier. This ports two commands over as a proof of concept to demonstrate that it works; more will be converted in follow-ups. Related to https://github.com/flutter/flutter/issues/83413 --- script/tool/CHANGELOG.md | 1 + script/tool/lib/src/common/core.dart | 22 +- .../src/common/package_looping_command.dart | 161 +++++++ .../tool/lib/src/common/plugin_command.dart | 3 + .../tool/lib/src/pubspec_check_command.dart | 39 +- script/tool/lib/src/xctest_command.dart | 89 ++-- .../common/package_looping_command_test.dart | 433 ++++++++++++++++++ .../tool/test/pubspec_check_command_test.dart | 22 +- script/tool/test/xctest_command_test.dart | 36 +- 9 files changed, 710 insertions(+), 96 deletions(-) create mode 100644 script/tool/lib/src/common/package_looping_command.dart create mode 100644 script/tool/test/common/package_looping_command_test.dart diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index ae81ced63662..f43d2fcc9bd7 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -6,6 +6,7 @@ - `xctest` now supports running macOS tests in addition to iOS - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. - The tooling now runs in strong null-safe mode. +- Modified the output format of `pubspec-check` and `xctest` ## 0.2.0 diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart index 1da6ef7a4c89..b2be8f56d172 100644 --- a/script/tool/lib/src/common/core.dart +++ b/script/tool/lib/src/common/core.dart @@ -53,13 +53,25 @@ bool isFlutterPackage(FileSystemEntity entity) { } } +/// Prints `successMessage` in green. +void printSuccess(String successMessage) { + print(Colorize(successMessage)..green()); +} + /// Prints `errorMessage` in red. void printError(String errorMessage) { - final Colorize redError = Colorize(errorMessage)..red(); - print(redError); + print(Colorize(errorMessage)..red()); } /// Error thrown when a command needs to exit with a non-zero exit code. +/// +/// While there is no specific definition of the meaning of different non-zero +/// exit codes for this tool, commands should follow the general convention: +/// 1: The command ran correctly, but found errors. +/// 2: The command failed to run because the arguments were invalid. +/// >2: The command failed to run correctly for some other reason. Ideally, +/// each such failure should have a unique exit code within the context of +/// that command. class ToolExit extends Error { /// Creates a tool exit with the given [exitCode]. ToolExit(this.exitCode); @@ -67,3 +79,9 @@ class ToolExit extends Error { /// The code that the process should exit with. final int exitCode; } + +/// A exit code for [ToolExit] for a successful run that found errors. +const int exitCommandFoundErrors = 1; + +/// A exit code for [ToolExit] for a failure to run due to invalid arguments. +const int exitInvalidArguments = 2; diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart new file mode 100644 index 000000000000..1349a5ed5dcc --- /dev/null +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -0,0 +1,161 @@ +// Copyright 2013 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 'package:colorize/colorize.dart'; +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; + +import 'core.dart'; +import 'plugin_command.dart'; +import 'process_runner.dart'; + +/// An abstract base class for a command that iterates over a set of packages +/// controlled by a standard set of flags, running some actions on each package, +/// and collecting and reporting the success/failure of those actions. +abstract class PackageLoopingCommand extends PluginCommand { + /// Creates a command to operate on [packagesDir] with the given environment. + PackageLoopingCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + GitDir? gitDir, + }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + + /// Called during [run] before any calls to [runForPackage]. This provides an + /// opportunity to fail early if the command can't be run (e.g., because the + /// arguments are invalid), and to set up any run-level state. + Future initializeRun() async {} + + /// Runs the command for [package], returning a list of errors. + /// + /// Errors may either be an empty string if there is no context that should + /// be included in the final error summary (e.g., a command that only has a + /// single failure mode), or strings that should be listed for that package + /// in the final summary. An empty list indicates success. + Future> runForPackage(Directory package); + + /// Whether or not the output (if any) of [runForPackage] is long, or short. + /// + /// This changes the logging that happens at the start of each package's + /// run; long output gets a banner-style message to make it easier to find, + /// while short output gets a single-line entry. + /// + /// When this is false, runForPackage output should be indented if possible, + /// to make the output structure easier to follow. + bool get hasLongOutput => true; + + /// Whether to loop over all packages (e.g., including example/), rather than + /// only top-level packages. + bool get includeSubpackages => false; + + /// The text to output at the start when reporting one or more failures. + /// This will be followed by a list of packages that reported errors, with + /// the per-package details if any. + /// + /// This only needs to be overridden if the summary should provide extra + /// context. + String get failureListHeader => 'The following packages had errors:'; + + /// The text to output at the end when reporting one or more failures. This + /// will be printed immediately after the a list of packages that reported + /// errors. + /// + /// This only needs to be overridden if the summary should provide extra + /// context. + String get failureListFooter => 'See above for full details.'; + + // ---------------------------------------- + + /// A convenience constant for [runForPackage] success that's more + /// self-documenting than the value. + static const List success = []; + + /// A convenience constant for [runForPackage] failure without additional + /// context that's more self-documenting than the value. + static const List failure = ['']; + + /// Prints a message using a standard format indicating that the package was + /// skipped, with an explanation of why. + void printSkip(String reason) { + print(Colorize('SKIPPING: $reason')..darkGray()); + } + + /// Returns the identifying name to use for [package]. + /// + /// Implementations should not expect a specific format for this string, since + /// it uses heuristics to try to be precise without being overly verbose. If + /// an exact format (e.g., published name, or basename) is required, that + /// should be used instead. + String getPackageDescription(Directory package) { + String packageName = p.relative(package.path, from: packagesDir.path); + final List components = p.split(packageName); + // For the common federated plugin pattern of `foo/foo_subpackage`, drop + // the first part since it's not useful. + if (components.length == 2 && + components[1].startsWith('${components[0]}_')) { + packageName = components[1]; + } + return packageName; + } + + // ---------------------------------------- + + @override + Future run() async { + await initializeRun(); + + final List packages = includeSubpackages + ? await getPackages().toList() + : await getPlugins().toList(); + + final Map> results = >{}; + for (final Directory package in packages) { + _printPackageHeading(package); + results[package] = await runForPackage(package); + } + + // If there were any errors reported, summarize them and exit. + if (results.values.any((List failures) => failures.isNotEmpty)) { + const String indentation = ' '; + printError(failureListHeader); + for (final Directory package in packages) { + final List errors = results[package]!; + if (errors.isNotEmpty) { + final String errorIndentation = indentation * 2; + String errorDetails = errors.join('\n$errorIndentation'); + if (errorDetails.isNotEmpty) { + errorDetails = ':\n$errorIndentation$errorDetails'; + } + printError( + '$indentation${getPackageDescription(package)}$errorDetails'); + } + } + printError(failureListFooter); + throw ToolExit(exitCommandFoundErrors); + } + + printSuccess('\n\nNo issues found!'); + } + + /// Prints the status message indicating that the command is being run for + /// [package]. + /// + /// Something is always printed to make it easier to distinguish between + /// a command running for a package and producing no output, and a command + /// not having been run for a package. + void _printPackageHeading(Directory package) { + String heading = 'Running for ${getPackageDescription(package)}'; + if (hasLongOutput) { + heading = ''' + +============================================================ +|| $heading +============================================================ +'''; + } else { + heading = '$heading...'; + } + print(Colorize(heading)..cyan()); + } +} diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 1ab9d8dcc6e0..4c095858e45a 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -14,6 +14,7 @@ import 'git_version_finder.dart'; import 'process_runner.dart'; /// Interface definition for all commands in this tool. +// TODO(stuartmorgan): Move most of this logic to PackageLoopingCommand. abstract class PluginCommand extends Command { /// Creates a command to operate on [packagesDir] with the given environment. PluginCommand( @@ -136,6 +137,8 @@ abstract class PluginCommand extends Command { /// Returns the root Dart package folders of the plugins involved in this /// command execution. + // TODO(stuartmorgan): Rename/restructure this, _getAllPlugins, and + // getPackages, as the current naming is very confusing. Stream getPlugins() async* { // To avoid assuming consistency of `Directory.list` across command // invocations, we collect and sort the plugin folders before sharding. diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 480d3a4c1190..44b6b061542c 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -4,11 +4,9 @@ import 'package:file/file.dart'; import 'package:git/git.dart'; -import 'package:path/path.dart' as p; import 'package:pubspec_parse/pubspec_parse.dart'; -import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; /// A command to enforce pubspec conventions across the repository. @@ -16,7 +14,7 @@ import 'common/process_runner.dart'; /// This both ensures that repo best practices for which optional fields are /// used are followed, and that the structure is consistent to make edits /// across multiple pubspec files easier. -class PubspecCheckCommand extends PluginCommand { +class PubspecCheckCommand extends PackageLoopingCommand { /// Creates an instance of the version check command. PubspecCheckCommand( Directory packagesDir, { @@ -52,29 +50,20 @@ class PubspecCheckCommand extends PluginCommand { 'Checks that pubspecs follow repository conventions.'; @override - Future run() async { - final List failingPackages = []; - await for (final Directory package in getPackages()) { - final String relativePackagePath = - p.relative(package.path, from: packagesDir.path); - print('Checking $relativePackagePath...'); - final File pubspec = package.childFile('pubspec.yaml'); - final bool passesCheck = !pubspec.existsSync() || - await _checkPubspec(pubspec, packageName: package.basename); - if (!passesCheck) { - failingPackages.add(relativePackagePath); - } - } + bool get hasLongOutput => false; - if (failingPackages.isNotEmpty) { - print('The following packages have pubspec issues:'); - for (final String package in failingPackages) { - print(' $package'); - } - throw ToolExit(1); - } + @override + bool get includeSubpackages => true; - print('\nNo pubspec issues found!'); + @override + Future> runForPackage(Directory package) async { + final File pubspec = package.childFile('pubspec.yaml'); + final bool passesCheck = !pubspec.existsSync() || + await _checkPubspec(pubspec, packageName: package.basename); + if (!passesCheck) { + return PackageLoopingCommand.failure; + } + return PackageLoopingCommand.success; } Future _checkPubspec( diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 741aa9d72837..3f50feef6fe9 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -9,7 +9,7 @@ import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; @@ -19,12 +19,15 @@ const String _kXCRunCommand = 'xcrun'; const String _kFoundNoSimulatorsMessage = 'Cannot find any available simulators, tests failed'; +const int _exitFindingSimulatorsFailed = 3; +const int _exitNoSimulators = 4; + /// The command to run XCTests (XCUnitTest and XCUITest) in plugins. /// The tests target have to be added to the Xcode project of the example app, /// usually at "example/{ios,macos}/Runner.xcworkspace". /// /// The static analyzer is also run. -class XCTestCommand extends PluginCommand { +class XCTestCommand extends PackageLoopingCommand { /// Creates an instance of the test command. XCTestCommand( Directory packagesDir, { @@ -41,6 +44,9 @@ class XCTestCommand extends PluginCommand { argParser.addFlag(kPlatformMacos, help: 'Runs the macOS tests'); } + // The device destination flags for iOS tests. + List _iosDestinationFlags = []; + @override final String name = 'xctest'; @@ -50,62 +56,53 @@ class XCTestCommand extends PluginCommand { 'This command requires "flutter" and "xcrun" to be in your path.'; @override - Future run() async { - final bool testIos = getBoolArg(kPlatformIos); - final bool testMacos = getBoolArg(kPlatformMacos); + String get failureListHeader => 'The following packages are failing XCTests:'; - if (!(testIos || testMacos)) { - print('At least one platform flag must be provided.'); - throw ToolExit(2); + @override + Future initializeRun() async { + final bool shouldTestIos = getBoolArg(kPlatformIos); + final bool shouldTestMacos = getBoolArg(kPlatformMacos); + + if (!(shouldTestIos || shouldTestMacos)) { + printError('At least one platform flag must be provided.'); + throw ToolExit(exitInvalidArguments); } - List iosDestinationFlags = []; - if (testIos) { + if (shouldTestIos) { String destination = getStringArg(_kiOSDestination); if (destination.isEmpty) { final String? simulatorId = await _findAvailableIphoneSimulator(); if (simulatorId == null) { - print(_kFoundNoSimulatorsMessage); - throw ToolExit(1); + printError(_kFoundNoSimulatorsMessage); + throw ToolExit(_exitNoSimulators); } destination = 'id=$simulatorId'; } - iosDestinationFlags = [ + _iosDestinationFlags = [ '-destination', destination, ]; } + } - final List failingPackages = []; - await for (final Directory plugin in getPlugins()) { - final String packageName = - p.relative(plugin.path, from: packagesDir.path); - print('============================================================'); - print('Start running for $packageName...'); - bool passed = true; - if (testIos) { - passed &= await _testPlugin(plugin, 'iOS', - extraXcrunFlags: iosDestinationFlags); - } - if (testMacos) { - passed &= await _testPlugin(plugin, 'macOS'); - } - if (!passed) { - failingPackages.add(packageName); - } - } + @override + Future> runForPackage(Directory package) async { + final List failures = []; + final bool testIos = getBoolArg(kPlatformIos); + final bool testMacos = getBoolArg(kPlatformMacos); + // Only provide the failing platform(s) in the summary if testing multiple + // platforms, otherwise it's just noise. + final bool provideErrorDetails = testIos && testMacos; - // Command end, print reports. - if (failingPackages.isEmpty) { - print('All XCTests have passed!'); - } else { - print( - 'The following packages are failing XCTests (see above for details):'); - for (final String package in failingPackages) { - print(' * $package'); - } - throw ToolExit(1); + if (testIos && + !await _testPlugin(package, 'iOS', + extraXcrunFlags: _iosDestinationFlags)) { + failures.add(provideErrorDetails ? 'iOS' : ''); + } + if (testMacos && !await _testPlugin(package, 'macOS')) { + failures.add(provideErrorDetails ? 'macOS' : ''); } + return failures; } /// Runs all applicable tests for [plugin], printing status and returning @@ -117,8 +114,7 @@ class XCTestCommand extends PluginCommand { }) async { if (!pluginSupportsPlatform(platform.toLowerCase(), plugin, requiredMode: PlatformSupport.inline)) { - print('$platform is not implemented by this plugin package.'); - print('\n'); + printSkip('$platform is not implemented by this plugin package.\n'); return true; } bool passing = true; @@ -136,7 +132,7 @@ class XCTestCommand extends PluginCommand { extraFlags: extraXcrunFlags); } if (exitCode == 0) { - print('Successfully ran $platform xctest for $examplePath'); + printSuccess('Successfully ran $platform xctest for $examplePath'); } else { passing = false; } @@ -184,9 +180,10 @@ class XCTestCommand extends PluginCommand { final io.ProcessResult findSimulatorsResult = await processRunner.run(_kXCRunCommand, findSimulatorsArguments); if (findSimulatorsResult.exitCode != 0) { - print('Error occurred while running "$findSimulatorCompleteCommand":\n' + printError( + 'Error occurred while running "$findSimulatorCompleteCommand":\n' '${findSimulatorsResult.stderr}'); - throw ToolExit(1); + throw ToolExit(_exitFindingSimulatorsFailed); } final Map simulatorListJson = jsonDecode(findSimulatorsResult.stdout as String) diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart new file mode 100644 index 000000000000..1012f764a62e --- /dev/null +++ b/script/tool/test/common/package_looping_command_test.dart @@ -0,0 +1,433 @@ +// Copyright 2013 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:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/package_looping_command.dart'; +import 'package:flutter_plugin_tools/src/common/process_runner.dart'; +import 'package:git/git.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../util.dart'; +import 'plugin_command_test.mocks.dart'; + +// Constants for colorized output start and end. +const String _startHeadingColor = '\x1B[36m'; +const String _startSkipColor = '\x1B[90m'; +const String _startSuccessColor = '\x1B[32m'; +const String _startErrorColor = '\x1B[31m'; +const String _endColor = '\x1B[0m'; + +// The filename within a package containing errors to return from runForPackage. +const String _errorFile = 'errors'; + +void main() { + late FileSystem fileSystem; + late Directory packagesDir; + late Directory thirdPartyPackagesDir; + + setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + thirdPartyPackagesDir = packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages'); + }); + + /// Creates a TestPackageLoopingCommand instance that uses [gitDiffResponse] + /// for git diffs, and logs output to [printOutput]. + TestPackageLoopingCommand createTestCommand({ + String gitDiffResponse = '', + bool hasLongOutput = true, + bool includeSubpackages = false, + bool failsDuringInit = false, + String? customFailureListHeader, + String? customFailureListFooter, + }) { + // Set up the git diff response. + final MockGitDir gitDir = MockGitDir(); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + final MockProcessResult mockProcessResult = MockProcessResult(); + if (invocation.positionalArguments[0][0] == 'diff') { + when(mockProcessResult.stdout as String?) + .thenReturn(gitDiffResponse); + } + return Future.value(mockProcessResult); + }); + + return TestPackageLoopingCommand( + packagesDir, + hasLongOutput: hasLongOutput, + includeSubpackages: includeSubpackages, + failsDuringInit: failsDuringInit, + customFailureListHeader: customFailureListHeader, + customFailureListFooter: customFailureListFooter, + gitDir: gitDir, + ); + } + + /// Runs [command] with the given [arguments], and returns its output. + Future> runCommand( + TestPackageLoopingCommand command, { + List arguments = const [], + void Function(Error error)? errorHandler, + }) async { + late CommandRunner runner; + runner = CommandRunner('test_package_looping_command', + 'Test for base package looping functionality'); + runner.addCommand(command); + return await runCapturingPrint( + runner, + [command.name, ...arguments], + errorHandler: errorHandler, + ); + } + + group('tool exit', () { + test('is handled during initializeRun', () async { + final TestPackageLoopingCommand command = + createTestCommand(failsDuringInit: true); + + expect(() => runCommand(command), throwsA(isA())); + }); + + test('does not stop looping', () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + failingPackage.childFile(_errorFile).createSync(); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + '${_startHeadingColor}Running for package_c...$_endColor', + ])); + }); + }); + + group('package iteration', () { + test('includes plugins and packages', () async { + final Directory plugin = createFakePlugin('a_plugin', packagesDir); + final Directory package = createFakePackage('a_package', packagesDir); + + final TestPackageLoopingCommand command = createTestCommand(); + await runCommand(command); + + expect(command.checkedPackages, + unorderedEquals([plugin.path, package.path])); + }); + + test('includes third_party/packages', () async { + final Directory package1 = createFakePackage('a_package', packagesDir); + final Directory package2 = + createFakePackage('another_package', thirdPartyPackagesDir); + + final TestPackageLoopingCommand command = createTestCommand(); + await runCommand(command); + + expect(command.checkedPackages, + unorderedEquals([package1.path, package2.path])); + }); + + test('includes subpackages when requested', () async { + final Directory plugin = createFakePlugin('a_plugin', packagesDir, + examples: ['example1', 'example2']); + final Directory package = createFakePackage('a_package', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(includeSubpackages: true); + await runCommand(command); + + expect( + command.checkedPackages, + unorderedEquals([ + plugin.path, + plugin.childDirectory('example').childDirectory('example1').path, + plugin.childDirectory('example').childDirectory('example2').path, + package.path, + package.childDirectory('example').path, + ])); + }); + }); + + group('output', () { + test('has the expected package headers for long-form output', () async { + createFakePlugin('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true); + final List output = await runCommand(command); + + const String separator = + '============================================================'; + expect( + output, + containsAllInOrder([ + '$_startHeadingColor\n$separator\n|| Running for package_a\n$separator\n$_endColor', + '$_startHeadingColor\n$separator\n|| Running for package_b\n$separator\n$_endColor', + ])); + }); + + test('has the expected package headers for short-form output', () async { + createFakePlugin('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + ])); + }); + + test('shows the success message when nothing fails', () async { + createFakePackage('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '$_startSuccessColor\n\nNo issues found!$_endColor', + ])); + }); + + test('shows failure summaries when something fails without extra details', + () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage1 = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + final Directory failingPackage2 = + createFakePlugin('package_d', packagesDir); + failingPackage1.childFile(_errorFile).createSync(); + failingPackage2.childFile(_errorFile).createSync(); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startErrorColor}The following packages had errors:$_endColor', + '$_startErrorColor package_b$_endColor', + '$_startErrorColor package_d$_endColor', + '${_startErrorColor}See above for full details.$_endColor', + ])); + }); + + test('uses custom summary header and footer if provided', () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage1 = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + final Directory failingPackage2 = + createFakePlugin('package_d', packagesDir); + failingPackage1.childFile(_errorFile).createSync(); + failingPackage2.childFile(_errorFile).createSync(); + + final TestPackageLoopingCommand command = createTestCommand( + hasLongOutput: false, + customFailureListHeader: 'This is a custom header', + customFailureListFooter: 'And a custom footer!'); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startErrorColor}This is a custom header$_endColor', + '$_startErrorColor package_b$_endColor', + '$_startErrorColor package_d$_endColor', + '${_startErrorColor}And a custom footer!$_endColor', + ])); + }); + + test('shows failure summaries when something fails with extra details', + () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage1 = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + final Directory failingPackage2 = + createFakePlugin('package_d', packagesDir); + final File errorFile1 = failingPackage1.childFile(_errorFile); + errorFile1.createSync(); + errorFile1.writeAsStringSync('just one detail'); + final File errorFile2 = failingPackage2.childFile(_errorFile); + errorFile2.createSync(); + errorFile2.writeAsStringSync('first detail\nsecond detail'); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startErrorColor}The following packages had errors:$_endColor', + '$_startErrorColor package_b:\n just one detail$_endColor', + '$_startErrorColor package_d:\n first detail\n second detail$_endColor', + '${_startErrorColor}See above for full details.$_endColor', + ])); + }); + }); + + group('utility', () { + test('printSkip has expected output', () async { + final TestPackageLoopingCommand command = + TestPackageLoopingCommand(packagesDir); + + final List printBuffer = []; + Zone.current.fork(specification: ZoneSpecification( + print: (_, __, ___, String message) { + printBuffer.add(message); + }, + )).run(() => command.printSkip('For a reason')); + + expect(printBuffer.first, + '${_startSkipColor}SKIPPING: For a reason$_endColor'); + }); + + test('getPackageDescription prints packageDir-relative paths by default', + () async { + final TestPackageLoopingCommand command = + TestPackageLoopingCommand(packagesDir); + + expect( + command.getPackageDescription(packagesDir.childDirectory('foo')), + 'foo', + ); + expect( + command.getPackageDescription(packagesDir + .childDirectory('foo') + .childDirectory('bar') + .childDirectory('baz')), + 'foo/bar/baz', + ); + }); + + test( + 'getPackageDescription elides group name in grouped federated plugin structure', + () async { + final TestPackageLoopingCommand command = + TestPackageLoopingCommand(packagesDir); + + expect( + command.getPackageDescription(packagesDir + .childDirectory('a_plugin') + .childDirectory('a_plugin_platform_interface')), + 'a_plugin_platform_interface', + ); + expect( + command.getPackageDescription(packagesDir + .childDirectory('a_plugin') + .childDirectory('a_plugin_web')), + 'a_plugin_web', + ); + }); + }); +} + +class TestPackageLoopingCommand extends PackageLoopingCommand { + TestPackageLoopingCommand( + Directory packagesDir, { + this.hasLongOutput = true, + this.includeSubpackages = false, + this.customFailureListHeader, + this.customFailureListFooter, + this.failsDuringInit = false, + ProcessRunner processRunner = const ProcessRunner(), + GitDir? gitDir, + }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + + final List checkedPackages = []; + + final String? customFailureListHeader; + final String? customFailureListFooter; + + final bool failsDuringInit; + + @override + bool hasLongOutput; + + @override + bool includeSubpackages; + + @override + String get failureListHeader => + customFailureListHeader ?? super.failureListHeader; + + @override + String get failureListFooter => + customFailureListFooter ?? super.failureListFooter; + + @override + final String name = 'loop-test'; + + @override + final String description = 'sample package looping command'; + + @override + Future initializeRun() async { + if (failsDuringInit) { + throw ToolExit(2); + } + } + + @override + Future> runForPackage(Directory package) async { + checkedPackages.add(package.path); + final File errorFile = package.childFile(_errorFile); + if (errorFile.existsSync()) { + final List errors = errorFile.readAsLinesSync(); + return errors.isNotEmpty ? errors : PackageLoopingCommand.failure; + } + return PackageLoopingCommand.success; + } +} + +class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index af27ac5bd2fe..38182a4d183f 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -104,10 +104,10 @@ ${devDependenciesSection()} expect( output, - containsAllInOrder([ - 'Checking plugin...', - 'Checking plugin/example...', - '\nNo pubspec issues found!', + containsAllInOrder([ + contains('Running for plugin...'), + contains('Running for plugin/example...'), + contains('No issues found!'), ]), ); }); @@ -129,10 +129,10 @@ ${flutterSection()} expect( output, - containsAllInOrder([ - 'Checking plugin...', - 'Checking plugin/example...', - '\nNo pubspec issues found!', + containsAllInOrder([ + contains('Running for plugin...'), + contains('Running for plugin/example...'), + contains('No issues found!'), ]), ); }); @@ -153,9 +153,9 @@ ${dependenciesSection()} expect( output, - containsAllInOrder([ - 'Checking package...', - '\nNo pubspec issues found!', + containsAllInOrder([ + contains('Running for package...'), + contains('No issues found!'), ]), ); }); diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 08af85b39e4d..b12ad852cda7 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -125,7 +125,9 @@ void main() { final List output = await runCapturingPrint(runner, ['xctest', '--ios', _kDestination, 'foo_destination']); expect( - output, contains('iOS is not implemented by this plugin package.')); + output, + contains( + contains('iOS is not implemented by this plugin package.'))); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -142,7 +144,9 @@ void main() { final List output = await runCapturingPrint(runner, ['xctest', '--ios', _kDestination, 'foo_destination']); expect( - output, contains('iOS is not implemented by this plugin package.')); + output, + contains( + contains('iOS is not implemented by this plugin package.'))); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -176,10 +180,12 @@ void main() { 'plugin1' ]); - expect(output, isNot(contains('Start running for plugin1...'))); - expect(output, contains('Start running for plugin2...')); - expect(output, - contains('Successfully ran iOS xctest for plugin2/example')); + expect(output, isNot(contains(contains('Running for plugin1')))); + expect(output, contains(contains('Running for plugin2'))); + expect( + output, + contains( + contains('Successfully ran iOS xctest for plugin2/example'))); expect( processRunner.recordedCalls, @@ -271,8 +277,10 @@ void main() { processRunner.processToReturn = mockProcess; final List output = await runCapturingPrint(runner, ['xctest', '--macos', _kDestination, 'foo_destination']); - expect(output, - contains('macOS is not implemented by this plugin package.')); + expect( + output, + contains( + contains('macOS is not implemented by this plugin package.'))); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -288,8 +296,10 @@ void main() { processRunner.processToReturn = mockProcess; final List output = await runCapturingPrint(runner, ['xctest', '--macos', _kDestination, 'foo_destination']); - expect(output, - contains('macOS is not implemented by this plugin package.')); + expect( + output, + contains( + contains('macOS is not implemented by this plugin package.'))); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -314,8 +324,10 @@ void main() { '--macos', ]); - expect(output, - contains('Successfully ran macOS xctest for plugin/example')); + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); expect( processRunner.recordedCalls, From 04beebf2e3f6ae3fd6dde80308a948f9d2dd6f62 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Tue, 22 Jun 2021 14:11:05 -0700 Subject: [PATCH 063/364] [ci] Add release github action (#4061) --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000000..f274a79f38cb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: release +on: + push: + branches: + - master + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - name: "Install Flutter" + # Github Actions don't support templates so it is hard to share this snippet with another action + # If we eventually need to use this in more workflow, we could create a shell script that contains this + # snippet. + run: | + cd $HOME + git clone https://github.com/flutter/flutter.git --depth 1 -b stable _flutter + echo "$HOME/_flutter/bin" >> $GITHUB_PATH + cd $GITHUB_WORKSPACE + # Checks out a copy of the repo. + - name: Check out code + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + with: + fetch-depth: 0 # Fetch all history so the tool can get all the tags to determine version. + - name: Set up tools + run: dart pub get + working-directory: ${{ github.workspace }}/script/tool + + # # This workflow should be the last to run. So wait for all the other tests to succeed. + - name: Wait on all tests + uses: lewagon/wait-on-check-action@1b1630e169116b58a4b933d5ad7effc46d3d312d + with: + ref: ${{ github.sha }} + running-workflow-name: 'release' + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 # seconds + allowed-conclusions: success + + - name: run release + run: | + git config --global user.name ${{ secrets.USER_NAME }} + git config --global user.email ${{ secrets.USER_EMAIL }} + dart ./script/tool/lib/src/main.dart publish-plugin --all-changed --base-sha=HEAD~ --skip-confirmation --remote=origin + env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} + + env: + DEFAULT_BRANCH: master From 9a90a9d0c61d5578db060dd6867309ca37690e3b Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 22 Jun 2021 14:16:04 -0700 Subject: [PATCH 064/364] Add link to the version update entry (#4075) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3972cd29b8c7..a3a279ab2151 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ - [ ] I signed the [CLA]. - [ ] The title of the PR starts with the name of the plugin surrounded by square brackets, e.g. `[shared_preferences]` - [ ] I listed at least one issue that this PR fixes in the description above. -- [ ] I updated pubspec.yaml with an appropriate new version according to the [pub versioning philosophy]. +- [ ] I [updated pubspec.yaml](https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#version-and-changelog-updates) with an appropriate new version according to the [pub versioning philosophy]. - [ ] I updated CHANGELOG.md to add a description of the change. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making or feature I am adding, or Hixie said the PR is test exempt. From 730129a45b52bca567cc5cc0263743ff1bc047a3 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 23 Jun 2021 12:36:03 +0200 Subject: [PATCH 065/364] [in_app_purchase] billingClient launchPriceChangeConfirmationFlow (#4077) --- .../in_app_purchase_android/CHANGELOG.md | 4 + .../inapppurchase/InAppPurchasePlugin.java | 2 + .../inapppurchase/MethodCallHandlerImpl.java | 44 ++++++++- .../inapppurchase/MethodCallHandlerTest.java | 96 ++++++++++++++++++- .../example/lib/main.dart | 20 +++- .../billing_client_wrapper.dart | 20 ++++ ...pp_purchase_android_platform_addition.dart | 12 +++ .../in_app_purchase_android/pubspec.yaml | 2 +- .../billing_client_wrapper_test.dart | 40 ++++++++ ...rchase_android_platform_addition_test.dart | 40 ++++++++ 10 files changed, 274 insertions(+), 6 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 9066fab84d18..d41032dd7dcf 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.4 + +* Added support for launchPriceChangeConfirmationFlow in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition. + ## 0.1.3+1 * Add payment proxy. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index b96880571b9a..b21ab6992608 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -46,6 +46,8 @@ static final class MethodNames { static final String ACKNOWLEDGE_PURCHASE = "BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)"; static final String IS_FEATURE_SUPPORTED = "BillingClient#isFeatureSupported(String)"; + static final String LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW = + "BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)"; private MethodNames() {}; } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 473c21d3ba5b..780331848422 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -24,6 +24,7 @@ import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.PriceChangeFlowParams; import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.PurchaseHistoryResponseListener; import com.android.billingclient.api.SkuDetails; @@ -41,7 +42,7 @@ class MethodCallHandlerImpl private static final String TAG = "InAppPurchasePlugin"; private static final String LOAD_SKU_DOC_URL = - "https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/README.md#loading-products-for-sale"; + "https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/in_app_purchase/README.md#loading-products-for-sale"; @Nullable private BillingClient billingClient; private final BillingClientFactory billingClientFactory; @@ -148,6 +149,9 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { case InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED: isFeatureSupported((String) call.argument("feature"), result); break; + case InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW: + launchPriceChangeConfirmationFlow((String) call.argument("sku"), result); + break; default: result.notImplemented(); } @@ -374,6 +378,44 @@ private void updateCachedSkus(@Nullable List skuDetailsList) { } } + private void launchPriceChangeConfirmationFlow(String sku, MethodChannel.Result result) { + if (activity == null) { + result.error( + "ACTIVITY_UNAVAILABLE", + "launchPriceChangeConfirmationFlow is not available. " + + "This method must be run with the app in foreground.", + null); + return; + } + if (billingClientError(result)) { + return; + } + // Note that assert doesn't work on Android (see https://stackoverflow.com/a/6176529/5167831 and https://stackoverflow.com/a/8164195/5167831) + // and that this assert is only added to silence the analyser. The actual null check + // is handled by the `billingClientError()` call. + assert billingClient != null; + + SkuDetails skuDetails = cachedSkus.get(sku); + if (skuDetails == null) { + result.error( + "NOT_FOUND", + String.format( + "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s", + sku, LOAD_SKU_DOC_URL), + null); + return; + } + + PriceChangeFlowParams params = + new PriceChangeFlowParams.Builder().setSkuDetails(skuDetails).build(); + billingClient.launchPriceChangeConfirmationFlow( + activity, + params, + billingResult -> { + result.success(Translator.fromBillingResult(billingResult)); + }); + } + private boolean billingClientError(MethodChannel.Result result) { if (billingClient != null) { return false; diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index 7465e6a56250..6f9256cd07bd 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -10,6 +10,7 @@ import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES; @@ -52,6 +53,8 @@ import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.PriceChangeConfirmationListener; +import com.android.billingclient.api.PriceChangeFlowParams; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase.PurchasesResult; import com.android.billingclient.api.PurchaseHistoryRecord; @@ -722,7 +725,7 @@ public void acknowledgePurchase() { } @Test - public void endConnection_if_activity_dettached() { + public void endConnection_if_activity_detached() { InAppPurchasePlugin plugin = new InAppPurchasePlugin(); plugin.setMethodCallHandler(methodChannelHandler); mockStartConnection(); @@ -768,6 +771,97 @@ public void isFutureSupported_false() { verify(result).success(false); } + @Test + public void launchPriceChangeConfirmationFlow() { + // Set up the sku details + establishConnectedBillingClient(null, null); + String skuId = "foo"; + queryForSkus(singletonList(skuId)); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.OK) + .setDebugMessage("dummy debug message") + .build(); + + // Set up the mock billing client + ArgumentCaptor priceChangeConfirmationListenerArgumentCaptor = + ArgumentCaptor.forClass(PriceChangeConfirmationListener.class); + ArgumentCaptor priceChangeFlowParamsArgumentCaptor = + ArgumentCaptor.forClass(PriceChangeFlowParams.class); + doNothing() + .when(mockBillingClient) + .launchPriceChangeConfirmationFlow( + any(), + priceChangeFlowParamsArgumentCaptor.capture(), + priceChangeConfirmationListenerArgumentCaptor.capture()); + + // Call the methodChannelHandler + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + methodChannelHandler.onMethodCall( + new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); + + // Verify the price change params. + PriceChangeFlowParams priceChangeFlowParams = priceChangeFlowParamsArgumentCaptor.getValue(); + assertEquals(skuId, priceChangeFlowParams.getSkuDetails().getSku()); + + // Set the response in the callback + PriceChangeConfirmationListener priceChangeConfirmationListener = + priceChangeConfirmationListenerArgumentCaptor.getValue(); + priceChangeConfirmationListener.onPriceChangeConfirmationResult(billingResult); + + // Verify we pass the response to result + verify(result, never()).error(any(), any(), any()); + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(HashMap.class); + verify(result, times(1)).success(resultCaptor.capture()); + assertEquals(fromBillingResult(billingResult), resultCaptor.getValue()); + } + + @Test + public void launchPriceChangeConfirmationFlow_withoutActivity_returnsActivityUnavailableError() { + // Set up the sku details + establishConnectedBillingClient(null, null); + String skuId = "foo"; + queryForSkus(singletonList(skuId)); + + methodChannelHandler.setActivity(null); + + // Call the methodChannelHandler + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + methodChannelHandler.onMethodCall( + new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); + verify(result, times(1)).error(eq("ACTIVITY_UNAVAILABLE"), any(), any()); + } + + @Test + public void launchPriceChangeConfirmationFlow_withoutSkuQuery_returnsNotFoundError() { + // Set up the sku details + establishConnectedBillingClient(null, null); + String skuId = "foo"; + + // Call the methodChannelHandler + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + methodChannelHandler.onMethodCall( + new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); + verify(result, times(1)).error(eq("NOT_FOUND"), contains("sku"), any()); + } + + @Test + public void launchPriceChangeConfirmationFlow_withoutBillingClient_returnsUnavailableError() { + // Set up the sku details + String skuId = "foo"; + + // Call the methodChannelHandler + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + methodChannelHandler.onMethodCall( + new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); + verify(result, times(1)).error(eq("UNAVAILABLE"), contains("BillingClient"), any()); + } + private ArgumentCaptor mockStartConnection() { Map arguments = new HashMap<>(); arguments.put("handle", 1); diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart index cb8cb75185e9..126734187380 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -31,8 +31,8 @@ const bool _kAutoConsume = true; const String _kConsumableId = 'consumable'; const String _kUpgradeId = 'upgrade'; -const String _kSilverSubscriptionId = 'subscription_silver'; -const String _kGoldSubscriptionId = 'subscription_gold'; +const String _kSilverSubscriptionId = 'subscription_silver1'; +const String _kGoldSubscriptionId = 'subscription_gold1'; const List _kProductIds = [ _kConsumableId, _kUpgradeId, @@ -251,7 +251,21 @@ class _MyAppState extends State<_MyApp> { productDetails.description, ), trailing: previousPurchase != null - ? Icon(Icons.check) + ? IconButton( + onPressed: () { + final InAppPurchaseAndroidPlatformAddition addition = + InAppPurchasePlatformAddition.instance + as InAppPurchaseAndroidPlatformAddition; + var skuDetails = + (productDetails as GooglePlayProductDetails) + .skuDetails; + addition + .launchPriceChangeConfirmationFlow( + sku: skuDetails.sku) + .then((value) => print( + "confirmationResponse: ${value.responseCode}")); + }, + icon: Icon(Icons.upgrade)) : TextButton( child: Text(productDetails.price), style: TextButton.styleFrom( diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index cf08fa95a580..4393d1d72eaf 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -311,6 +311,26 @@ class BillingClient { return result ?? false; } + /// Initiates a flow to confirm the change of price for an item subscribed by the user. + /// + /// When the price of a user subscribed item has changed, launch this flow to take users to + /// a screen with price change information. User can confirm the new price or cancel the flow. + /// + /// The skuDetails needs to have already been fetched in a [querySkuDetails] + /// call. + Future launchPriceChangeConfirmationFlow( + {required String sku}) async { + assert(sku != null); + final Map arguments = { + 'sku': sku, + }; + return BillingResultWrapper.fromJson((await channel.invokeMapMethod( + 'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)', + arguments)) ?? + {}); + } + /// The method call handler for [channel]. @visibleForTesting Future callHandler(MethodCall call) async { diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index fc4ab7cbf7dc..11b105aba96c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -141,4 +141,16 @@ class InAppPurchaseAndroidPlatformAddition Future isFeatureSupported(BillingClientFeature feature) async { return _billingClient.isFeatureSupported(feature); } + + /// Initiates a flow to confirm the change of price for an item subscribed by the user. + /// + /// When the price of a user subscribed item has changed, launch this flow to take users to + /// a screen with price change information. User can confirm the new price or cancel the flow. + /// + /// The skuDetails needs to have already been fetched in a + /// [InAppPurchaseAndroidPlatform.queryProductDetails] call. + Future launchPriceChangeConfirmationFlow( + {required String sku}) { + return _billingClient.launchPriceChangeConfirmationFlow(sku: sku); + } } diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index ad06d85a6d43..4f11cdfbed30 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3+1 +version: 0.1.4 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index 6ab1641984e9..02ae9ba33564 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -574,4 +574,44 @@ void main() { expect(arguments['feature'], equals('subscriptions')); }); }); + + group('launchPriceChangeConfirmationFlow', () { + const String launchPriceChangeConfirmationFlowMethodName = + 'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)'; + + final expectedBillingResultPriceChangeConfirmation = BillingResultWrapper( + responseCode: BillingResponse.ok, + debugMessage: 'dummy message', + ); + + test('serializes and deserializes data', () async { + stubPlatform.addResponse( + name: launchPriceChangeConfirmationFlowMethodName, + value: + buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), + ); + + expect( + await billingClient.launchPriceChangeConfirmationFlow( + sku: dummySkuDetails.sku, + ), + equals(expectedBillingResultPriceChangeConfirmation), + ); + }); + + test('passes sku to launchPriceChangeConfirmationFlow', () async { + stubPlatform.addResponse( + name: launchPriceChangeConfirmationFlowMethodName, + value: + buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), + ); + await billingClient.launchPriceChangeConfirmationFlow( + sku: dummySkuDetails.sku, + ); + final MethodCall call = stubPlatform + .previousCallMatching(launchPriceChangeConfirmationFlowMethodName); + expect(call.arguments, + equals({'sku': dummySkuDetails.sku})); + }); + }); } diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart index 0ef17e7eed33..a478cabac89b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -171,4 +171,44 @@ void main() { expect(arguments['feature'], equals('subscriptions')); }); }); + + group('launchPriceChangeConfirmationFlow', () { + const String launchPriceChangeConfirmationFlowMethodName = + 'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)'; + const dummySku = 'sku'; + + final expectedBillingResultPriceChangeConfirmation = BillingResultWrapper( + responseCode: BillingResponse.ok, + debugMessage: 'dummy message', + ); + + test('serializes and deserializes data', () async { + stubPlatform.addResponse( + name: launchPriceChangeConfirmationFlowMethodName, + value: + buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), + ); + + expect( + await iapAndroidPlatformAddition.launchPriceChangeConfirmationFlow( + sku: dummySku, + ), + equals(expectedBillingResultPriceChangeConfirmation), + ); + }); + + test('passes sku to launchPriceChangeConfirmationFlow', () async { + stubPlatform.addResponse( + name: launchPriceChangeConfirmationFlowMethodName, + value: + buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), + ); + await iapAndroidPlatformAddition.launchPriceChangeConfirmationFlow( + sku: dummySku, + ); + final MethodCall call = stubPlatform + .previousCallMatching(launchPriceChangeConfirmationFlowMethodName); + expect(call.arguments, equals({'sku': dummySku})); + }); + }); } From 0f64adf5179217d22ff61b7674a24ea219907984 Mon Sep 17 00:00:00 2001 From: Nikolay Nizruhin Date: Wed, 23 Jun 2021 16:21:05 +0200 Subject: [PATCH 066/364] [in_app_purchase] Update readme.md (#4049) --- packages/in_app_purchase/in_app_purchase/CHANGELOG.md | 4 ++++ packages/in_app_purchase/in_app_purchase/README.md | 1 + packages/in_app_purchase/in_app_purchase/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index d41c0d0d2aee..52bbff52bef0 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.6 + +* Added import flutter foundation dependency in README.md to be able to use `defaultTargetPlatform`. + ## 1.0.5 * Add explanation for casting `ProductDetails` and `PurchaseDetails` to platform specific implementations in the readme. diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index ad28cfacb695..28b3c0821cf3 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -73,6 +73,7 @@ The following initialization code is required for Google Play: // Import `in_app_purchase_android.dart` to be able to access the // `InAppPurchaseAndroidPlatformAddition` class. import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:flutter/foundation.dart'; void main() { // Inform the plugin that this app supports pending purchases on Android. diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index aa2e8fcdee6b..b589c24d3677 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.5 +version: 1.0.6 environment: sdk: ">=2.12.0 <3.0.0" From 8ae8506721420a0ac6f9a99b6b0b7913a6df0826 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Wed, 23 Jun 2021 17:26:04 +0300 Subject: [PATCH 067/364] Fixed typos and bumped version (#4050) --- packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md | 4 ++++ .../flutter/plugins/inapppurchase/MethodCallHandlerImpl.java | 2 +- packages/in_app_purchase/in_app_purchase_android/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index d41032dd7dcf..316a67b9ce99 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.4+1 + +* Fixed typos. + ## 0.1.4 * Added support for launchPriceChangeConfirmationFlow in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 780331848422..5b58808b2b49 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -334,7 +334,7 @@ private void startConnection( @Override public void onBillingSetupFinished(BillingResult billingResult) { if (alreadyFinished) { - Log.d(TAG, "Tried to call onBilllingSetupFinished multiple times."); + Log.d(TAG, "Tried to call onBillingSetupFinished multiple times."); return; } alreadyFinished = true; diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 4f11cdfbed30..e0de3411e0ff 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4 +version: 0.1.4+1 environment: sdk: ">=2.12.0 <3.0.0" From 9dd26e044857b830686dd78cc0e41f36ab473fb9 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Wed, 23 Jun 2021 09:27:15 -0700 Subject: [PATCH 068/364] [ci] increase auto publish job wait interval to 3 mins (#4086) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f274a79f38cb..ecda750661ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: ref: ${{ github.sha }} running-workflow-name: 'release' repo-token: ${{ secrets.GITHUB_TOKEN }} - wait-interval: 10 # seconds + wait-interval: 180 # seconds allowed-conclusions: success - name: run release From 77f4a367985e1c6edf243654bf8d9dd2eeea3c44 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Wed, 23 Jun 2021 21:11:05 +0200 Subject: [PATCH 069/364] [camera] android-rework part 6: Android exposure- and focus point features (#4039) --- packages/camera/camera/android/build.gradle | 2 +- .../plugins/camera/CameraRegionUtils.java | 161 ++++++++ .../exposurepoint/ExposurePointFeature.java | 60 +-- .../focuspoint/FocusPointFeature.java | 88 +++++ .../plugins/camera/types/CameraRegions.java | 199 ---------- .../plugins/camera/CameraRegionUtilsTest.java | 353 ++++++++++++++++++ .../ExposurePointFeatureTest.java | 231 ++++++++---- .../focuspoint/FocusPointFeatureTest.java | 281 ++++++++++++++ .../types/CameraRegionsFactoryTest.java | 201 ---------- .../camera/types/CameraRegionsTest.java | 114 ------ 10 files changed, 1077 insertions(+), 613 deletions(-) create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java delete mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraRegions.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java delete mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsFactoryTest.java delete mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsTest.java diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle index 0907c1eeecc9..65c6d26edb49 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -49,7 +49,7 @@ android { dependencies { compileOnly 'androidx.annotation:annotation:1.1.0' testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-inline:3.5.13' + testImplementation 'org.mockito:mockito-inline:3.11.1' testImplementation 'androidx.test:core:1.3.0' testImplementation 'org.robolectric:robolectric:4.3' } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java new file mode 100644 index 000000000000..ff8a49f1d148 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java @@ -0,0 +1,161 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import android.annotation.TargetApi; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.os.Build; +import android.util.Size; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import java.util.Arrays; + +/** + * Utility class offering functions to calculate values regarding the camera boundaries. + * + *

The functions are used to calculate focus and exposure settings. + */ +public final class CameraRegionUtils { + + /** + * Obtains the boundaries for the currently active camera, that can be used for calculating + * MeteringRectangle instances required for setting focus or exposure settings. + * + * @param cameraProperties - Collection of the characteristics for the current camera device. + * @param requestBuilder - The request builder for the current capture request. + * @return The boundaries for the current camera device. + */ + public static Size getCameraBoundaries( + @NonNull CameraProperties cameraProperties, @NonNull CaptureRequest.Builder requestBuilder) { + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && supportsDistortionCorrection(cameraProperties)) { + // Get the current distortion correction mode. + Integer distortionCorrectionMode = + requestBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE); + + // Return the correct boundaries depending on the mode. + android.graphics.Rect rect; + if (distortionCorrectionMode == null + || distortionCorrectionMode == CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) { + rect = cameraProperties.getSensorInfoPreCorrectionActiveArraySize(); + } else { + rect = cameraProperties.getSensorInfoActiveArraySize(); + } + + return SizeFactory.create(rect.width(), rect.height()); + } else { + // No distortion correction support. + return cameraProperties.getSensorInfoPixelArraySize(); + } + } + + /** + * Converts a point into a {@link MeteringRectangle} with the supplied coordinates as the center + * point. + * + *

Since the Camera API (due to cross-platform constraints) only accepts a point when + * configuring a specific focus or exposure area and Android requires a rectangle to configure + * these settings there is a need to convert the point into a rectangle. This method will create + * the required rectangle with an arbitrarily size that is a 10th of the current viewport and the + * coordinates as the center point. + * + * @param boundaries - The camera boundaries to calculate the metering rectangle for. + * @param x x - 1 >= coordinate >= 0. + * @param y y - 1 >= coordinate >= 0. + * @return The dimensions of the metering rectangle based on the supplied coordinates and + * boundaries. + */ + public static MeteringRectangle convertPointToMeteringRectangle( + @NonNull Size boundaries, double x, double y) { + assert (boundaries.getWidth() > 0 && boundaries.getHeight() > 0); + assert (x >= 0 && x <= 1); + assert (y >= 0 && y <= 1); + + // Interpolate the target coordinate. + int targetX = (int) Math.round(x * ((double) (boundaries.getWidth() - 1))); + int targetY = (int) Math.round(y * ((double) (boundaries.getHeight() - 1))); + // Determine the dimensions of the metering rectangle (10th of the viewport). + int targetWidth = (int) Math.round(((double) boundaries.getWidth()) / 10d); + int targetHeight = (int) Math.round(((double) boundaries.getHeight()) / 10d); + // Adjust target coordinate to represent top-left corner of metering rectangle. + targetX -= targetWidth / 2; + targetY -= targetHeight / 2; + // Adjust target coordinate as to not fall out of bounds. + if (targetX < 0) { + targetX = 0; + } + if (targetY < 0) { + targetY = 0; + } + int maxTargetX = boundaries.getWidth() - 1 - targetWidth; + int maxTargetY = boundaries.getHeight() - 1 - targetHeight; + if (targetX > maxTargetX) { + targetX = maxTargetX; + } + if (targetY > maxTargetY) { + targetY = maxTargetY; + } + + // Build the metering rectangle. + return MeteringRectangleFactory.create(targetX, targetY, targetWidth, targetHeight, 1); + } + + @TargetApi(Build.VERSION_CODES.P) + private static boolean supportsDistortionCorrection(CameraProperties cameraProperties) { + int[] availableDistortionCorrectionModes = + cameraProperties.getDistortionCorrectionAvailableModes(); + if (availableDistortionCorrectionModes == null) { + availableDistortionCorrectionModes = new int[0]; + } + long nonOffModesSupported = + Arrays.stream(availableDistortionCorrectionModes) + .filter((value) -> value != CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) + .count(); + return nonOffModesSupported > 0; + } + + /** Factory class that assists in creating a {@link MeteringRectangle} instance. */ + static class MeteringRectangleFactory { + /** + * Creates a new instance of the {@link MeteringRectangle} class. + * + *

This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param x coordinate >= 0. + * @param y coordinate >= 0. + * @param width width >= 0. + * @param height height >= 0. + * @param meteringWeight weight between {@value MeteringRectangle#METERING_WEIGHT_MIN} and + * {@value MeteringRectangle#METERING_WEIGHT_MAX} inclusively + * @return new instance of the {@link MeteringRectangle} class. + * @throws IllegalArgumentException if any of the parameters were negative. + */ + @VisibleForTesting + public static MeteringRectangle create( + int x, int y, int width, int height, int meteringWeight) { + return new MeteringRectangle(x, y, width, height, meteringWeight); + } + } + + /** Factory class that assists in creating a {@link Size} instance. */ + static class SizeFactory { + /** + * Creates a new instance of the {@link Size} class. + * + *

This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param width width >= 0. + * @param height height >= 0. + * @return new instance of the {@link Size} class. + */ + @VisibleForTesting + public static Size create(int width, int height) { + return new Size(width, height); + } + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java index f729d33c8528..8c2ee6167846 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java @@ -6,28 +6,37 @@ import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.MeteringRectangle; -import android.util.Log; +import android.util.Size; import androidx.annotation.NonNull; import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.CameraRegionUtils; import io.flutter.plugins.camera.features.CameraFeature; import io.flutter.plugins.camera.features.Point; -import io.flutter.plugins.camera.types.CameraRegions; /** Exposure point controls where in the frame exposure metering will come from. */ public class ExposurePointFeature extends CameraFeature { - private final CameraRegions cameraRegions; - private Point currentSetting = new Point(0.0, 0.0); + private Size cameraBoundaries; + private Point exposurePoint; + private MeteringRectangle exposureRectangle; /** * Creates a new instance of the {@link ExposurePointFeature}. * * @param cameraProperties Collection of the characteristics for the current camera device. - * @param cameraRegions Utility class to assist in calculating exposure boundaries. */ - public ExposurePointFeature(CameraProperties cameraProperties, CameraRegions cameraRegions) { + public ExposurePointFeature(CameraProperties cameraProperties) { super(cameraProperties); - this.cameraRegions = cameraRegions; + } + + /** + * Sets the camera boundaries that are required for the exposure point feature to function. + * + * @param cameraBoundaries - The camera boundaries to set. + */ + public void setCameraBoundaries(@NonNull Size cameraBoundaries) { + this.cameraBoundaries = cameraBoundaries; + this.buildExposureRectangle(); } @Override @@ -37,18 +46,13 @@ public String getDebugName() { @Override public Point getValue() { - return currentSetting; + return exposurePoint; } @Override - public void setValue(@NonNull Point value) { - this.currentSetting = value; - - if (value.x == null || value.y == null) { - cameraRegions.resetAutoExposureMeteringRectangle(); - } else { - cameraRegions.setAutoExposureMeteringRectangleFromPoint(value.x, value.y); - } + public void setValue(Point value) { + this.exposurePoint = (value == null || value.x == null || value.y == null) ? null : value; + this.buildExposureRectangle(); } // Whether or not this camera can set the exposure point. @@ -63,16 +67,22 @@ public void updateBuilder(CaptureRequest.Builder requestBuilder) { if (!checkIsSupported()) { return; } - - MeteringRectangle aeRect = null; - try { - aeRect = cameraRegions.getAEMeteringRectangle(); - } catch (Exception e) { - Log.w("Camera", "Unable to retrieve the Auto Exposure metering rectangle.", e); - } - requestBuilder.set( CaptureRequest.CONTROL_AE_REGIONS, - aeRect == null ? null : new MeteringRectangle[] {aeRect}); + exposureRectangle == null ? null : new MeteringRectangle[] {exposureRectangle}); + } + + private void buildExposureRectangle() { + if (this.cameraBoundaries == null) { + throw new AssertionError( + "The cameraBoundaries should be set (using `ExposurePointFeature.setCameraBoundaries(Size)`) before updating the exposure point."); + } + if (this.exposurePoint == null) { + this.exposureRectangle = null; + } else { + this.exposureRectangle = + CameraRegionUtils.convertPointToMeteringRectangle( + this.cameraBoundaries, this.exposurePoint.x, this.exposurePoint.y); + } } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java new file mode 100644 index 000000000000..92fcfa9f1132 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java @@ -0,0 +1,88 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.focuspoint; + +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.CameraRegionUtils; +import io.flutter.plugins.camera.features.CameraFeature; +import io.flutter.plugins.camera.features.Point; + +/** Focus point controls where in the frame focus will come from. */ +public class FocusPointFeature extends CameraFeature { + + private Size cameraBoundaries; + private Point focusPoint; + private MeteringRectangle focusRectangle; + + /** + * Creates a new instance of the {@link FocusPointFeature}. + * + * @param cameraProperties Collection of the characteristics for the current camera device. + */ + public FocusPointFeature(CameraProperties cameraProperties) { + super(cameraProperties); + } + + /** + * Sets the camera boundaries that are required for the focus point feature to function. + * + * @param cameraBoundaries - The camera boundaries to set. + */ + public void setCameraBoundaries(@NonNull Size cameraBoundaries) { + this.cameraBoundaries = cameraBoundaries; + this.buildFocusRectangle(); + } + + @Override + public String getDebugName() { + return "FocusPointFeature"; + } + + @Override + public Point getValue() { + return focusPoint; + } + + @Override + public void setValue(Point value) { + this.focusPoint = value == null || value.x == null || value.y == null ? null : value; + this.buildFocusRectangle(); + } + + // Whether or not this camera can set the focus point. + @Override + public boolean checkIsSupported() { + Integer supportedRegions = cameraProperties.getControlMaxRegionsAutoFocus(); + return supportedRegions != null && supportedRegions > 0; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + if (!checkIsSupported()) { + return; + } + requestBuilder.set( + CaptureRequest.CONTROL_AF_REGIONS, + focusRectangle == null ? null : new MeteringRectangle[] {focusRectangle}); + } + + private void buildFocusRectangle() { + if (this.cameraBoundaries == null) { + throw new AssertionError( + "The cameraBoundaries should be set (using `FocusPointFeature.setCameraBoundaries(Size)`) before updating the focus point."); + } + if (this.focusPoint == null) { + this.focusRectangle = null; + } else { + this.focusRectangle = + CameraRegionUtils.convertPointToMeteringRectangle( + this.cameraBoundaries, this.focusPoint.x, this.focusPoint.y); + } + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraRegions.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraRegions.java deleted file mode 100644 index b86241e78d29..000000000000 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraRegions.java +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera.types; - -import android.annotation.TargetApi; -import android.hardware.camera2.CaptureRequest; -import android.hardware.camera2.params.MeteringRectangle; -import android.os.Build; -import android.util.Size; -import androidx.annotation.NonNull; -import io.flutter.plugins.camera.CameraProperties; -import java.util.Arrays; - -/** - * Utility class that contains information regarding the camera's regions. - * - *

The regions information is used to calculate focus and exposure settings. - */ -public final class CameraRegions { - - /** Factory class that assists in creating a {@link CameraRegions} instance. */ - public static class Factory { - /** - * Creates a new instance of the {@link CameraRegions} class. - * - *

The {@link CameraProperties} and {@link CaptureRequest.Builder} classed are used to - * determine if the device's camera supports distortion correction mode and calculate the - * correct boundaries based on the outcome. - * - * @param cameraProperties Collection of the characteristics for the current camera device. - * @param requestBuilder CaptureRequest builder containing current target and surface settings. - * @return new instance of the {@link CameraRegions} class. - */ - public static CameraRegions create( - @NonNull CameraProperties cameraProperties, - @NonNull CaptureRequest.Builder requestBuilder) { - Size boundaries; - - // No distortion correction support - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - && supportsDistortionCorrection(cameraProperties)) { - // Get the current distortion correction mode - Integer distortionCorrectionMode = - requestBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE); - - // Return the correct boundaries depending on the mode - android.graphics.Rect rect; - if (distortionCorrectionMode == null - || distortionCorrectionMode == CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) { - rect = cameraProperties.getSensorInfoPreCorrectionActiveArraySize(); - } else { - rect = cameraProperties.getSensorInfoActiveArraySize(); - } - - // Set new region size - boundaries = rect == null ? null : new Size(rect.width(), rect.height()); - } else { - boundaries = cameraProperties.getSensorInfoPixelArraySize(); - } - - // Create new camera regions using new size - return new CameraRegions(boundaries); - } - - @TargetApi(Build.VERSION_CODES.P) - private static boolean supportsDistortionCorrection(CameraProperties cameraProperties) { - int[] availableDistortionCorrectionModes = - cameraProperties.getDistortionCorrectionAvailableModes(); - if (availableDistortionCorrectionModes == null) { - availableDistortionCorrectionModes = new int[0]; - } - long nonOffModesSupported = - Arrays.stream(availableDistortionCorrectionModes) - .filter((value) -> value != CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) - .count(); - return nonOffModesSupported > 0; - } - } - - private final Size boundaries; - - private MeteringRectangle aeMeteringRectangle; - private MeteringRectangle afMeteringRectangle; - - /** - * Creates a new instance of the {@link CameraRegions} class. - * - * @param boundaries The area of the image sensor. - */ - CameraRegions(Size boundaries) { - assert (boundaries == null || boundaries.getWidth() > 0); - assert (boundaries == null || boundaries.getHeight() > 0); - - this.boundaries = boundaries; - } - - /** - * Gets the {@link MeteringRectangle} on which the auto exposure will be applied. - * - * @return The {@link MeteringRectangle} on which the auto exposure will be applied. - */ - public MeteringRectangle getAEMeteringRectangle() { - return aeMeteringRectangle; - } - - /** - * Gets the {@link MeteringRectangle} on which the auto focus will be applied. - * - * @return The {@link MeteringRectangle} on which the auto focus will be applied. - */ - public MeteringRectangle getAFMeteringRectangle() { - return afMeteringRectangle; - } - - /** - * Gets the area of the image sensor. - * - *

If distortion correction is supported the size corresponds to the active pixels after any - * geometric distortion correction has been applied. If distortion correction is not supported the - * dimensions include the full pixel array, possibly including black calibration pixels. - * - * @return The area of the image sensor. - */ - public Size getBoundaries() { - return this.boundaries; - } - - /** Resets the {@link MeteringRectangle} on which the auto exposure will be applied. */ - public void resetAutoExposureMeteringRectangle() { - this.aeMeteringRectangle = null; - } - - /** - * Sets the coordinates which will form the centre of the exposure rectangle. - * - * @param x x – coordinate >= 0 - * @param y y – coordinate >= 0 - */ - public void setAutoExposureMeteringRectangleFromPoint(double x, double y) { - this.aeMeteringRectangle = convertPointToMeteringRectangle(x, y); - } - - /** Resets the {@link MeteringRectangle} on which the auto focus will be applied. */ - public void resetAutoFocusMeteringRectangle() { - this.afMeteringRectangle = null; - } - - /** - * Sets the coordinates which will form the centre of the focus rectangle. - * - * @param x x – coordinate >= 0 - * @param y y – coordinate >= 0 - */ - public void setAutoFocusMeteringRectangleFromPoint(double x, double y) { - this.afMeteringRectangle = convertPointToMeteringRectangle(x, y); - } - - /** - * Converts a point into a {@link MeteringRectangle} with the supplied coordinates as the centre - * point. - * - *

Since the Camera API (due to cross-platform constraints) only accepts a point when - * configuring a specific focus or exposure area and Android requires a rectangle to configure - * these settings there is a need to convert the point into a rectangle. This method will create - * the required rectangle with an arbitrarily size that is a 10th of the current viewport and the - * coordinates as the centre point. - * - * @param x x - coordinate >= 0 - * @param y y - coordinate >= 0 - * @return The dimensions of the metering rectangle based on the supplied coordinates. - */ - MeteringRectangle convertPointToMeteringRectangle(double x, double y) { - assert (x >= 0 && x <= 1); - assert (y >= 0 && y <= 1); - - // Interpolate the target coordinate - int targetX = (int) Math.round(x * ((double) (boundaries.getWidth() - 1))); - int targetY = (int) Math.round(y * ((double) (boundaries.getHeight() - 1))); - // Since the Camera API only allows Determine the dimensions of the metering rectangle (10th of - // the viewport) - int targetWidth = (int) Math.round(((double) boundaries.getWidth()) / 10d); - int targetHeight = (int) Math.round(((double) boundaries.getHeight()) / 10d); - // Adjust target coordinate to represent top-left corner of metering rectangle - targetX -= targetWidth / 2; - targetY -= targetHeight / 2; - // Adjust target coordinate as to not fall out of bounds - if (targetX < 0) targetX = 0; - if (targetY < 0) targetY = 0; - int maxTargetX = boundaries.getWidth() - 1 - targetWidth; - int maxTargetY = boundaries.getHeight() - 1 - targetHeight; - if (targetX > maxTargetX) targetX = maxTargetX; - if (targetY > maxTargetY) targetY = maxTargetY; - - // Build the metering rectangle - return new MeteringRectangle(targetX, targetY, targetWidth, targetHeight, 1); - } -} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java new file mode 100644 index 000000000000..2d65c4e0fc05 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java @@ -0,0 +1,353 @@ +// Copyright 2013 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. +package io.flutter.plugins.camera; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.graphics.Rect; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.os.Build; +import android.util.Size; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +public class CameraRegionUtilsTest { + + Size mockCameraBoundaries; + + @Before + public void setUp() { + this.mockCameraBoundaries = mock(Size.class); + when(this.mockCameraBoundaries.getWidth()).thenReturn(100); + when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + } + + @Test + public void + getCameraBoundaries_should_return_sensor_info_pixel_array_size_when_running_pre_android_p() { + updateSdkVersion(Build.VERSION_CODES.O_MR1); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockCameraBoundaries); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(mockCameraBoundaries, result); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_should_return_sensor_info_pixel_array_size_when_distortion_correction_is_null() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()).thenReturn(null); + when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockCameraBoundaries); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(mockCameraBoundaries, result); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_should_return_sensor_info_pixel_array_size_when_distortion_correction_is_off() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn(new int[] {CaptureRequest.DISTORTION_CORRECTION_MODE_OFF}); + when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockCameraBoundaries); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(mockCameraBoundaries, result); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_should_return_info_pre_correction_active_array_size_when_distortion_correction_mode_is_set_to_null() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + Rect mockSensorInfoPreCorrectionActiveArraySize = mock(Rect.class); + when(mockSensorInfoPreCorrectionActiveArraySize.width()).thenReturn(100); + when(mockSensorInfoPreCorrectionActiveArraySize.height()).thenReturn(100); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn( + new int[] { + CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, + CaptureRequest.DISTORTION_CORRECTION_MODE_FAST + }); + when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)).thenReturn(null); + when(mockCameraProperties.getSensorInfoPreCorrectionActiveArraySize()) + .thenReturn(mockSensorInfoPreCorrectionActiveArraySize); + + try (MockedStatic mockedSizeFactory = + mockStatic(CameraRegionUtils.SizeFactory.class)) { + mockedSizeFactory + .when(() -> CameraRegionUtils.SizeFactory.create(anyInt(), anyInt())) + .thenAnswer( + (Answer) + invocation -> { + Size mockSize = mock(Size.class); + when(mockSize.getWidth()).thenReturn(invocation.getArgument(0)); + when(mockSize.getHeight()).thenReturn(invocation.getArgument(1)); + return mockSize; + }); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(100, result.getWidth()); + assertEquals(100, result.getHeight()); + verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_should_return_info_pre_correction_active_array_size_when_distortion_correction_mode_is_set_to_off() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + Rect mockSensorInfoPreCorrectionActiveArraySize = mock(Rect.class); + when(mockSensorInfoPreCorrectionActiveArraySize.width()).thenReturn(100); + when(mockSensorInfoPreCorrectionActiveArraySize.height()).thenReturn(100); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn( + new int[] { + CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, + CaptureRequest.DISTORTION_CORRECTION_MODE_FAST + }); + + when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)) + .thenReturn(CaptureRequest.DISTORTION_CORRECTION_MODE_OFF); + when(mockCameraProperties.getSensorInfoPreCorrectionActiveArraySize()) + .thenReturn(mockSensorInfoPreCorrectionActiveArraySize); + + try (MockedStatic mockedSizeFactory = + mockStatic(CameraRegionUtils.SizeFactory.class)) { + mockedSizeFactory + .when(() -> CameraRegionUtils.SizeFactory.create(anyInt(), anyInt())) + .thenAnswer( + (Answer) + invocation -> { + Size mockSize = mock(Size.class); + when(mockSize.getWidth()).thenReturn(invocation.getArgument(0)); + when(mockSize.getHeight()).thenReturn(invocation.getArgument(1)); + return mockSize; + }); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(100, result.getWidth()); + assertEquals(100, result.getHeight()); + verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_should_return_sensor_info_active_array_size_when_distortion_correction_mode_is_set() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + Rect mockSensorInfoActiveArraySize = mock(Rect.class); + when(mockSensorInfoActiveArraySize.width()).thenReturn(100); + when(mockSensorInfoActiveArraySize.height()).thenReturn(100); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn( + new int[] { + CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, + CaptureRequest.DISTORTION_CORRECTION_MODE_FAST + }); + + when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)) + .thenReturn(CaptureRequest.DISTORTION_CORRECTION_MODE_FAST); + when(mockCameraProperties.getSensorInfoActiveArraySize()) + .thenReturn(mockSensorInfoActiveArraySize); + + try (MockedStatic mockedSizeFactory = + mockStatic(CameraRegionUtils.SizeFactory.class)) { + mockedSizeFactory + .when(() -> CameraRegionUtils.SizeFactory.create(anyInt(), anyInt())) + .thenAnswer( + (Answer) + invocation -> { + Size mockSize = mock(Size.class); + when(mockSize.getWidth()).thenReturn(invocation.getArgument(0)); + when(mockSize.getHeight()).thenReturn(invocation.getArgument(1)); + return mockSize; + }); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(100, result.getWidth()); + assertEquals(100, result.getHeight()); + verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + } + } finally { + updateSdkVersion(0); + } + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_x_upper_bound() { + CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 1.5, 0); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_x_lower_bound() { + CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, -0.5, 0); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_y_upper_bound() { + CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0, 1.5); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_y_lower_bound() { + CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0, -0.5); + } + + @Test + public void getMeteringRectangleForPoint_should_return_valid_MeteringRectangle() { + try (MockedStatic mockedMeteringRectangleFactory = + mockStatic(CameraRegionUtils.MeteringRectangleFactory.class)) { + + mockedMeteringRectangleFactory + .when( + () -> + CameraRegionUtils.MeteringRectangleFactory.create( + anyInt(), anyInt(), anyInt(), anyInt(), anyInt())) + .thenAnswer( + new Answer() { + @Override + public MeteringRectangle answer(InvocationOnMock createInvocation) + throws Throwable { + MeteringRectangle mockMeteringRectangle = mock(MeteringRectangle.class); + when(mockMeteringRectangle.getX()).thenReturn(createInvocation.getArgument(0)); + when(mockMeteringRectangle.getY()).thenReturn(createInvocation.getArgument(1)); + when(mockMeteringRectangle.getWidth()) + .thenReturn(createInvocation.getArgument(2)); + when(mockMeteringRectangle.getHeight()) + .thenReturn(createInvocation.getArgument(3)); + when(mockMeteringRectangle.getMeteringWeight()) + .thenReturn(createInvocation.getArgument(4)); + when(mockMeteringRectangle.equals(any())) + .thenAnswer( + new Answer() { + @Override + public Boolean answer(InvocationOnMock equalsInvocation) + throws Throwable { + MeteringRectangle otherMockMeteringRectangle = + equalsInvocation.getArgument(0); + return mockMeteringRectangle.getX() + == otherMockMeteringRectangle.getX() + && mockMeteringRectangle.getY() + == otherMockMeteringRectangle.getY() + && mockMeteringRectangle.getWidth() + == otherMockMeteringRectangle.getWidth() + && mockMeteringRectangle.getHeight() + == otherMockMeteringRectangle.getHeight() + && mockMeteringRectangle.getMeteringWeight() + == otherMockMeteringRectangle.getMeteringWeight(); + } + }); + return mockMeteringRectangle; + } + }); + + MeteringRectangle r; + // Center + r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0.5, 0.5); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(45, 45, 10, 10, 1).equals(r)); + + // Top left + r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0.0, 0.0); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 0, 10, 10, 1).equals(r)); + + // Bottom right + r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 1.0, 1.0); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 89, 10, 10, 1).equals(r)); + + // Top left + r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0.0, 1.0); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 89, 10, 10, 1).equals(r)); + + // Top right + r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 1.0, 0.0); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 0, 10, 10, 1).equals(r)); + } + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_0_width_boundary() { + new io.flutter.plugins.camera.CameraRegions(new Size(0, 50)); + } + + @Test(expected = AssertionError.class) + public void getMeteringRectangleForPoint_should_throw_for_0_height_boundary() { + new io.flutter.plugins.camera.CameraRegions(new Size(100, 0)); + } + + private static void updateSdkVersion(int version) { + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", version); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java index 0aedc59ef635..4a515c6fd0ec 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java @@ -6,9 +6,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -17,41 +18,48 @@ import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.CameraRegionUtils; import io.flutter.plugins.camera.features.Point; -import io.flutter.plugins.camera.types.CameraRegions; +import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; public class ExposurePointFeatureTest { + + Size mockCameraBoundaries; + + @Before + public void setUp() { + this.mockCameraBoundaries = mock(Size.class); + when(this.mockCameraBoundaries.getWidth()).thenReturn(100); + when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + } + @Test public void getDebugName_should_return_the_name_of_the_feature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); + CameraRegionUtils mockCameraRegions = mock(CameraRegionUtils.class); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); assertEquals("ExposurePointFeature", exposurePointFeature.getDebugName()); } @Test - public void getValue_should_return_default_point_if_not_set() { + public void getValue_should_return_null_if_not_set() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); - Point expectedPoint = new Point(0.0, 0.0); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); Point actualPoint = exposurePointFeature.getValue(); - - assertEquals(expectedPoint.x, actualPoint.x); - assertEquals(expectedPoint.y, actualPoint.y); + assertNull(exposurePointFeature.getValue()); } @Test public void getValue_should_echo_the_set_value() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); Point expectedPoint = new Point(0.0, 0.0); exposurePointFeature.setValue(expectedPoint); @@ -63,45 +71,114 @@ public void getValue_should_echo_the_set_value() { @Test public void setValue_should_reset_point_when_x_coord_is_null() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); exposurePointFeature.setValue(new Point(null, 0.0)); - verify(mockCameraRegions, times(1)).resetAutoExposureMeteringRectangle(); + assertNull(exposurePointFeature.getValue()); } @Test public void setValue_should_reset_point_when_y_coord_is_null() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); exposurePointFeature.setValue(new Point(0.0, null)); - verify(mockCameraRegions, times(1)).resetAutoExposureMeteringRectangle(); + assertNull(exposurePointFeature.getValue()); } @Test - public void setValue_should_reset_point_when_valid_coords_are_supplied() { + public void setValue_should_set_point_when_valid_coords_are_supplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); Point point = new Point(0.0, 0.0); exposurePointFeature.setValue(point); - verify(mockCameraRegions, times(1)).setAutoExposureMeteringRectangleFromPoint(point.x, point.y); + assertEquals(point, exposurePointFeature.getValue()); + } + + @Test + public void + setValue_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + Size mockedCameraBoundaries = mock(Size.class); + exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + exposurePointFeature.setValue(new Point(0.5, 0.5)); + + mockedCameraRegionUtils.verify( + () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + times(1)); + } + } + + @Test(expected = AssertionError.class) + public void setValue_should_throw_assertion_error_when_no_valid_boundaries_are_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + exposurePointFeature.setValue(new Point(0.5, 0.5)); + } + } + + @Test + public void setValue_should_not_determine_metering_rectangle_when_null_coords_are_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + Size mockedCameraBoundaries = mock(Size.class); + exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + exposurePointFeature.setValue(null); + exposurePointFeature.setValue(new Point(null, 0.5)); + exposurePointFeature.setValue(new Point(0.5, null)); + + mockedCameraRegionUtils.verifyNoInteractions(); + } + } + + @Test + public void + setCameraBoundaries_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); + exposurePointFeature.setValue(new Point(0.5, 0.5)); + Size mockedCameraBoundaries = mock(Size.class); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); + + mockedCameraRegionUtils.verify( + () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + times(1)); + } } @Test public void checkIsSupported_should_return_false_when_max_regions_is_null() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, null); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(null); @@ -111,8 +188,8 @@ public void checkIsSupported_should_return_false_when_max_regions_is_null() { @Test public void checkIsSupported_should_return_false_when_max_regions_is_zero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, null); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(0); @@ -122,8 +199,8 @@ public void checkIsSupported_should_return_false_when_max_regions_is_zero() { @Test public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_zero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, null); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); @@ -133,64 +210,72 @@ public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_ @Test public void updateBuilder_should_return_when_checkIsSupported_is_false() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(0); - exposurePointFeature.updateBuilder(null); + exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); - verify(mockCameraRegions, never()).getAEMeteringRectangle(); + verify(mockCaptureRequestBuilder, never()).set(any(), any()); } @Test - public void updateBuilder_should_set_ae_regions_to_null_when_ae_metering_rectangle_is_null() { + public void + updateBuilder_should_set_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); - when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - when(mockCameraRegions.getAEMeteringRectangle()).thenReturn(null); - - exposurePointFeature.updateBuilder(mockBuilder); - - verify(mockBuilder, times(1)).set(CaptureRequest.CONTROL_AE_REGIONS, null); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + Size mockedCameraBoundaries = mock(Size.class); + MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + mockedCameraRegionUtils + .when( + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, 0.5, 0.5)) + .thenReturn(mockedMeteringRectangle); + exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); + exposurePointFeature.setValue(new Point(0.5, 0.5)); + + exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); + } + + verify(mockCaptureRequestBuilder, times(1)) + .set(CaptureRequest.CONTROL_AE_REGIONS, new MeteringRectangle[] {mockedMeteringRectangle}); } @Test - public void updateBuilder_should_set_ae_regions_with_metering_rectangle() { + public void + updateBuilder_should_not_set_metering_rectangle_when_no_valid_boundaries_are_supplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); - MeteringRectangle meteringRectangle = new MeteringRectangle(0, 0, 0, 0, 0); - when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - when(mockCameraRegions.getAEMeteringRectangle()).thenReturn(meteringRectangle); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); - exposurePointFeature.updateBuilder(mockBuilder); + exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); - verify(mockBuilder, times(1)) - .set(eq(CaptureRequest.CONTROL_AE_REGIONS), any(MeteringRectangle[].class)); + verify(mockCaptureRequestBuilder, times(1)).set(any(), isNull()); } @Test - public void updateBuilder_should_silently_fail_when_exception_occurs() { + public void updateBuilder_should_not_set_metering_rectangle_when_no_valid_coords_are_supplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegions mockCameraRegions = mock(CameraRegions.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = - new ExposurePointFeature(mockCameraProperties, mockCameraRegions); - when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - when(mockCameraRegions.getAEMeteringRectangle()).thenThrow(new IllegalArgumentException()); - - exposurePointFeature.updateBuilder(mockBuilder); - - verify(mockBuilder, times(1)).set(CaptureRequest.CONTROL_AE_REGIONS, null); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + exposurePointFeature.setValue(null); + exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); + exposurePointFeature.setValue(new Point(0d, null)); + exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); + exposurePointFeature.setValue(new Point(null, 0d)); + exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); + verify(mockCaptureRequestBuilder, times(3)).set(any(), isNull()); } } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java new file mode 100644 index 000000000000..d158336ef235 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java @@ -0,0 +1,281 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features.focuspoint; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.CameraRegionUtils; +import io.flutter.plugins.camera.features.Point; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +public class FocusPointFeatureTest { + + Size mockCameraBoundaries; + + @Before + public void setUp() { + this.mockCameraBoundaries = mock(Size.class); + when(this.mockCameraBoundaries.getWidth()).thenReturn(100); + when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + } + + @Test + public void getDebugName_should_return_the_name_of_the_feature() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CameraRegionUtils mockCameraRegions = mock(CameraRegionUtils.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + + assertEquals("FocusPointFeature", focusPointFeature.getDebugName()); + } + + @Test + public void getValue_should_return_null_if_not_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + Point actualPoint = focusPointFeature.getValue(); + assertNull(focusPointFeature.getValue()); + } + + @Test + public void getValue_should_echo_the_set_value() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + Point expectedPoint = new Point(0.0, 0.0); + + focusPointFeature.setValue(expectedPoint); + Point actualPoint = focusPointFeature.getValue(); + + assertEquals(expectedPoint, actualPoint); + } + + @Test + public void setValue_should_reset_point_when_x_coord_is_null() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + focusPointFeature.setValue(new Point(null, 0.0)); + + assertNull(focusPointFeature.getValue()); + } + + @Test + public void setValue_should_reset_point_when_y_coord_is_null() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + focusPointFeature.setValue(new Point(0.0, null)); + + assertNull(focusPointFeature.getValue()); + } + + @Test + public void setValue_should_set_point_when_valid_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + Point point = new Point(0.0, 0.0); + + focusPointFeature.setValue(point); + + assertEquals(point, focusPointFeature.getValue()); + } + + @Test + public void + setValue_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + Size mockedCameraBoundaries = mock(Size.class); + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + focusPointFeature.setValue(new Point(0.5, 0.5)); + + mockedCameraRegionUtils.verify( + () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + times(1)); + } + } + + @Test(expected = AssertionError.class) + public void setValue_should_throw_assertion_error_when_no_valid_boundaries_are_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + focusPointFeature.setValue(new Point(0.5, 0.5)); + } + } + + @Test + public void setValue_should_not_determine_metering_rectangle_when_null_coords_are_set() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + Size mockedCameraBoundaries = mock(Size.class); + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + focusPointFeature.setValue(null); + focusPointFeature.setValue(new Point(null, 0.5)); + focusPointFeature.setValue(new Point(0.5, null)); + + mockedCameraRegionUtils.verifyNoInteractions(); + } + } + + @Test + public void + setCameraBoundaries_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + focusPointFeature.setValue(new Point(0.5, 0.5)); + Size mockedCameraBoundaries = mock(Size.class); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + + mockedCameraRegionUtils.verify( + () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + times(1)); + } + } + + @Test + public void checkIsSupported_should_return_false_when_max_regions_is_null() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(new Size(100, 100)); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(null); + + assertFalse(focusPointFeature.checkIsSupported()); + } + + @Test + public void checkIsSupported_should_return_false_when_max_regions_is_zero() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(new Size(100, 100)); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(0); + + assertFalse(focusPointFeature.checkIsSupported()); + } + + @Test + public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_zero() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(new Size(100, 100)); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + + assertTrue(focusPointFeature.checkIsSupported()); + } + + @Test + public void updateBuilder_should_return_when_checkIsSupported_is_false() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(0); + + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + + verify(mockCaptureRequestBuilder, never()).set(any(), any()); + } + + @Test + public void + updateBuilder_should_set_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + Size mockedCameraBoundaries = mock(Size.class); + MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + mockedCameraRegionUtils + .when( + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, 0.5, 0.5)) + .thenReturn(mockedMeteringRectangle); + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + focusPointFeature.setValue(new Point(0.5, 0.5)); + + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + } + + verify(mockCaptureRequestBuilder, times(1)) + .set(CaptureRequest.CONTROL_AE_REGIONS, new MeteringRectangle[] {mockedMeteringRectangle}); + } + + @Test + public void + updateBuilder_should_not_set_metering_rectangle_when_no_valid_boundaries_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); + + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + + verify(mockCaptureRequestBuilder, times(1)).set(any(), isNull()); + } + + @Test + public void updateBuilder_should_not_set_metering_rectangle_when_no_valid_coords_are_supplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + focusPointFeature.setValue(null); + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + focusPointFeature.setValue(new Point(0d, null)); + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + focusPointFeature.setValue(new Point(null, 0d)); + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + verify(mockCaptureRequestBuilder, times(3)).set(any(), isNull()); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsFactoryTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsFactoryTest.java deleted file mode 100644 index 5fa0c2c4a2a4..000000000000 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsFactoryTest.java +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera.types; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.hardware.camera2.CaptureRequest; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; -import android.util.Size; -import io.flutter.plugins.camera.CameraProperties; -import io.flutter.plugins.camera.utils.TestUtils; -import org.junit.Before; -import org.junit.Test; - -public class CameraRegionsFactoryTest { - private Size mockSize; - - @Before - public void before() { - mockSize = mock(Size.class); - - when(mockSize.getHeight()).thenReturn(640); - when(mockSize.getWidth()).thenReturn(480); - } - - @Test - public void - create_should_initialize_with_sensor_info_pixel_array_size_when_running_pre_android_p() { - updateSdkVersion(VERSION_CODES.O_MR1); - - try { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - - when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockSize); - - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertEquals(mockSize, cameraRegions.getBoundaries()); - verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); - verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); - } finally { - updateSdkVersion(0); - } - } - - @Test - public void - create_should_initialize_with_sensor_info_pixel_array_size_when_distortion_correction_is_null() { - updateSdkVersion(VERSION_CODES.P); - - try { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - - when(mockCameraProperties.getDistortionCorrectionAvailableModes()).thenReturn(null); - when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockSize); - - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertEquals(mockSize, cameraRegions.getBoundaries()); - verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); - verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); - } finally { - updateSdkVersion(0); - } - } - - @Test - public void - create_should_initialize_with_sensor_info_pixel_array_size_when_distortion_correction_is_off() { - updateSdkVersion(VERSION_CODES.P); - - try { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - - when(mockCameraProperties.getDistortionCorrectionAvailableModes()) - .thenReturn(new int[] {CaptureRequest.DISTORTION_CORRECTION_MODE_OFF}); - when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockSize); - - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertEquals(mockSize, cameraRegions.getBoundaries()); - verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); - verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); - } finally { - updateSdkVersion(0); - } - } - - @Test - public void - create_should_initialize_with_sensor_info_pre_correction_active_array_size_when_distortion_correction_mode_is_set_to_null() { - updateSdkVersion(VERSION_CODES.P); - - try { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - - when(mockCameraProperties.getDistortionCorrectionAvailableModes()) - .thenReturn( - new int[] { - CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, - CaptureRequest.DISTORTION_CORRECTION_MODE_FAST - }); - - when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)).thenReturn(null); - when(mockCameraProperties.getSensorInfoPreCorrectionActiveArraySize()).thenReturn(null); - - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertNull(cameraRegions.getBoundaries()); - verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); - verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); - } finally { - updateSdkVersion(0); - } - } - - @Test - public void - create_should_initialize_with_sensor_info_pre_correction_active_array_size_when_distortion_correction_mode_is_set_off() { - updateSdkVersion(VERSION_CODES.P); - - try { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - - when(mockCameraProperties.getDistortionCorrectionAvailableModes()) - .thenReturn( - new int[] { - CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, - CaptureRequest.DISTORTION_CORRECTION_MODE_FAST - }); - - when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)) - .thenReturn(CaptureRequest.DISTORTION_CORRECTION_MODE_OFF); - when(mockCameraProperties.getSensorInfoPreCorrectionActiveArraySize()).thenReturn(null); - - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertNull(cameraRegions.getBoundaries()); - verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); - verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); - } finally { - updateSdkVersion(0); - } - } - - @Test - public void - ctor_should_initialize_with_sensor_info_active_array_size_when_distortion_correction_mode_is_set() { - updateSdkVersion(VERSION_CODES.P); - - try { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - - when(mockCameraProperties.getDistortionCorrectionAvailableModes()) - .thenReturn( - new int[] { - CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, - CaptureRequest.DISTORTION_CORRECTION_MODE_FAST - }); - - when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)) - .thenReturn(CaptureRequest.DISTORTION_CORRECTION_MODE_FAST); - when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(null); - - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertNull(cameraRegions.getBoundaries()); - verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); - verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); - } finally { - updateSdkVersion(0); - } - } - - @Test - public void getBoundaries_should_return_null_if_not_set() { - CameraProperties mockCameraProperties = mock(CameraProperties.class); - CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); - CameraRegions cameraRegions = CameraRegions.Factory.create(mockCameraProperties, mockBuilder); - - assertNull(cameraRegions.getBoundaries()); - } - - private static void updateSdkVersion(int version) { - TestUtils.setFinalStatic(VERSION.class, "SDK_INT", version); - } -} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsTest.java deleted file mode 100644 index b760e1e9ca29..000000000000 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/CameraRegionsTest.java +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera.types; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -import android.hardware.camera2.params.MeteringRectangle; -import android.util.Size; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -@RunWith(RobolectricTestRunner.class) -public class CameraRegionsTest { - io.flutter.plugins.camera.types.CameraRegions cameraRegions; - - @Before - public void setUp() { - this.cameraRegions = new io.flutter.plugins.camera.types.CameraRegions(new Size(100, 100)); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_x_upper_bound() { - cameraRegions.convertPointToMeteringRectangle(1.5, 0); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_x_lower_bound() { - cameraRegions.convertPointToMeteringRectangle(-0.5, 0); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_y_upper_bound() { - cameraRegions.convertPointToMeteringRectangle(0, 1.5); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_y_lower_bound() { - cameraRegions.convertPointToMeteringRectangle(0, -0.5); - } - - @Test - public void getMeteringRectangleForPoint_should_return_valid_MeteringRectangle() { - MeteringRectangle r; - // Center - r = cameraRegions.convertPointToMeteringRectangle(0.5, 0.5); - assertEquals(new MeteringRectangle(45, 45, 10, 10, 1), r); - - // Top left - r = cameraRegions.convertPointToMeteringRectangle(0.0, 0.0); - assertEquals(new MeteringRectangle(0, 0, 10, 10, 1), r); - - // Bottom right - r = cameraRegions.convertPointToMeteringRectangle(1.0, 1.0); - assertEquals(new MeteringRectangle(89, 89, 10, 10, 1), r); - - // Top left - r = cameraRegions.convertPointToMeteringRectangle(0.0, 1.0); - assertEquals(new MeteringRectangle(0, 89, 10, 10, 1), r); - - // Top right - r = cameraRegions.convertPointToMeteringRectangle(1.0, 0.0); - assertEquals(new MeteringRectangle(89, 0, 10, 10, 1), r); - } - - @Test(expected = AssertionError.class) - public void constructor_should_throw_for_0_width_boundary() { - new io.flutter.plugins.camera.CameraRegions(new Size(0, 50)); - } - - @Test(expected = AssertionError.class) - public void constructor_should_throw_for_0_height_boundary() { - new io.flutter.plugins.camera.CameraRegions(new Size(100, 0)); - } - - @Test - public void setAutoExposureMeteringRectangleFromPoint_should_set_aeMeteringRectangle_for_point() { - cameraRegions.setAutoExposureMeteringRectangleFromPoint(0, 0); - assertEquals(new MeteringRectangle(0, 0, 10, 10, 1), cameraRegions.getAEMeteringRectangle()); - } - - @Test - public void resetAutoExposureMeteringRectangle_should_reset_aeMeteringRectangle() { - io.flutter.plugins.camera.types.CameraRegions cr = - new io.flutter.plugins.camera.types.CameraRegions(new Size(100, 50)); - cr.setAutoExposureMeteringRectangleFromPoint(0, 0); - assertNotNull(cr.getAEMeteringRectangle()); - cr.resetAutoExposureMeteringRectangle(); - assertNull(cr.getAEMeteringRectangle()); - } - - @Test - public void setAutoFocusMeteringRectangleFromPoint_should_set_afMeteringRectangle_for_point() { - io.flutter.plugins.camera.types.CameraRegions cr = - new io.flutter.plugins.camera.types.CameraRegions(new Size(100, 50)); - cr.setAutoFocusMeteringRectangleFromPoint(0, 0); - assertEquals(new MeteringRectangle(0, 0, 10, 5, 1), cr.getAFMeteringRectangle()); - } - - @Test - public void resetAutoFocusMeteringRectangle_should_reset_afMeteringRectangle() { - io.flutter.plugins.camera.types.CameraRegions cr = - new io.flutter.plugins.camera.types.CameraRegions(new Size(100, 50)); - cr.setAutoFocusMeteringRectangleFromPoint(0, 0); - assertNotNull(cr.getAFMeteringRectangle()); - cr.resetAutoFocusMeteringRectangle(); - assertNull(cr.getAFMeteringRectangle()); - } -} From 0c77ee26a2a014dbb8fd8b52d3131e461e207706 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Wed, 23 Jun 2021 19:18:00 -0700 Subject: [PATCH 070/364] [ci] Set release action owner as flutter (#4091) --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecda750661ee..f6753e5a2add 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: jobs: release: + if: github.repository_owner == 'flutter' name: release runs-on: ubuntu-latest steps: From a7c58acb6d6b48f870f4c320c83c16270797e626 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 24 Jun 2021 08:56:03 -0700 Subject: [PATCH 071/364] Disable the "needs publishing" auto-labeler (#4098) --- .github/workflows/pull_request_label.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml index 6b93864d3f3a..16694f4dbf1b 100644 --- a/.github/workflows/pull_request_label.yml +++ b/.github/workflows/pull_request_label.yml @@ -20,12 +20,3 @@ jobs: with: repo-token: "${{ secrets.GITHUB_TOKEN }}" sync-labels: true - - post_merge_label: - if: github.event.action == 'closed' && github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@9794b1493b6f1fa7b006c5f8635a19c76c98be95 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - configuration-path: .github/post_merge_labeler.yml From ca42231e4878082101f5f6b54a3e3f545e6d3876 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 24 Jun 2021 12:29:06 -0700 Subject: [PATCH 072/364] [webview_flutter] Suppress iOS 9 deprecation warnings (#4100) On master the project is auto-updated to iOS 9, causing CI failures on this deprecation warning (due to -Werrror), but still needs to support iOS 8 for current stable. --- packages/webview_flutter/CHANGELOG.md | 3 ++- packages/webview_flutter/ios/Classes/FlutterWebView.m | 10 ++++++++++ packages/webview_flutter/pubspec.yaml | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 9167f9240044..f43812d438f8 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 2.0.9 * Add iOS UI integration test target. +* Suppress deprecation warning for iOS APIs deprecated in iOS 9. ## 2.0.8 diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/ios/Classes/FlutterWebView.m index b6f0b268ab02..c6d926d3cfc2 100644 --- a/packages/webview_flutter/ios/Classes/FlutterWebView.m +++ b/packages/webview_flutter/ios/Classes/FlutterWebView.m @@ -391,15 +391,25 @@ - (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy case 0: // require_user_action_for_all_media_types if (@available(iOS 10.0, *)) { configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; + } else if (@available(iOS 9.0, *)) { + configuration.requiresUserActionForMediaPlayback = true; } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" configuration.mediaPlaybackRequiresUserAction = true; +#pragma clang diagnostic pop } break; case 1: // always_allow if (@available(iOS 10.0, *)) { configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; + } else if (@available(iOS 9.0, *)) { + configuration.requiresUserActionForMediaPlayback = false; } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" configuration.mediaPlaybackRequiresUserAction = false; +#pragma clang diagnostic pop } break; default: diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index c062118b1d14..6acee01924a6 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.8 +version: 2.0.9 environment: sdk: ">=2.12.0 <3.0.0" From d1c3e83e84c50ac0be06aa921043bb73c2936ec6 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Thu, 24 Jun 2021 12:46:24 -0700 Subject: [PATCH 073/364] [flutter_plugin_tools] `publish-plugin` check against pub to determine if a release should happen (#4068) This PR removes a TODO where we used to check if a release should happen against git tags, instead, we check against pub. Also, when auto-publish and the package is manually released, the CI will continue to fail without this change. Fixes flutter/flutter#81047 --- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/publish_plugin_command.dart | 42 ++- .../test/publish_plugin_command_test.dart | 308 ++++++++++++++---- 3 files changed, 281 insertions(+), 70 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index f43d2fcc9bd7..3e2ec3f4c991 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -6,6 +6,7 @@ - `xctest` now supports running macOS tests in addition to iOS - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. - The tooling now runs in strong null-safe mode. +- `publish plugins` check against pub.dev to determine if a release should happen. - Modified the output format of `pubspec-check` and `xctest` ## 0.2.0 diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 70ec75bc7b76..622a1a3cb133 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -8,6 +8,7 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -18,6 +19,7 @@ import 'common/core.dart'; import 'common/git_version_finder.dart'; import 'common/plugin_command.dart'; import 'common/process_runner.dart'; +import 'common/pub_version_finder.dart'; @immutable class _RemoteInfo { @@ -49,7 +51,10 @@ class PublishPluginCommand extends PluginCommand { Print print = print, io.Stdin? stdinput, GitDir? gitDir, - }) : _print = print, + http.Client? httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + _print = print, _stdin = stdinput ?? io.stdin, super(packagesDir, processRunner: processRunner, gitDir: gitDir) { argParser.addOption( @@ -131,6 +136,7 @@ class PublishPluginCommand extends PluginCommand { final Print _print; final io.Stdin _stdin; StreamSubscription? _stdinSubscription; + final PubVersionFinder _pubVersionFinder; @override Future run() async { @@ -182,6 +188,8 @@ class PublishPluginCommand extends PluginCommand { remoteForTagPush: remote, ); } + + _pubVersionFinder.httpClient.close(); await _finish(successful); } @@ -196,6 +204,7 @@ class PublishPluginCommand extends PluginCommand { _print('No version updates in this commit.'); return true; } + _print('Getting existing tags...'); final io.ProcessResult existingTagsResult = await baseGitDir.runCommand(['tag', '--sort=-committerdate']); @@ -212,7 +221,6 @@ class PublishPluginCommand extends PluginCommand { .childFile(pubspecPath); final _CheckNeedsReleaseResult result = await _checkNeedsRelease( pubspecFile: pubspecFile, - gitVersionFinder: gitVersionFinder, existingTags: existingTags, ); switch (result) { @@ -271,7 +279,6 @@ class PublishPluginCommand extends PluginCommand { // Returns a [_CheckNeedsReleaseResult] that indicates the result. Future<_CheckNeedsReleaseResult> _checkNeedsRelease({ required File pubspecFile, - required GitVersionFinder gitVersionFinder, required List existingTags, }) async { if (!pubspecFile.existsSync()) { @@ -293,19 +300,24 @@ Safe to ignore if the package is deleted in this commit. return _CheckNeedsReleaseResult.failure; } - // Get latest tagged version and compare with the current version. - // TODO(cyanglaz): Check latest version of the package on pub instead of git - // https://github.com/flutter/flutter/issues/81047 - - final String latestTag = existingTags.firstWhere( - (String tag) => tag.split('-v').first == pubspec.name, - orElse: () => ''); - if (latestTag.isNotEmpty) { - final String latestTaggedVersion = latestTag.split('-v').last; - final Version latestVersion = Version.parse(latestTaggedVersion); - if (pubspec.version! < latestVersion) { + // Check if the package named `packageName` with `version` has already published. + final Version version = pubspec.version!; + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: pubspec.name); + if (pubVersionFinderResponse.versions.contains(version)) { + final String tagsForPackageWithSameVersion = existingTags.firstWhere( + (String tag) => + tag.split('-v').first == pubspec.name && + tag.split('-v').last == version.toString(), + orElse: () => ''); + _print( + 'The version $version of ${pubspec.name} has already been published'); + if (tagsForPackageWithSameVersion.isEmpty) { _print( - 'The new version (${pubspec.version}) is lower than the current version ($latestVersion) for ${pubspec.name}.\nThis git commit is a revert, no release is tagged.'); + 'However, the git release tag for this version (${pubspec.name}-v$version) is not found. Please manually fix the tag then run the command again.'); + return _CheckNeedsReleaseResult.failure; + } else { + _print('skip.'); return _CheckNeedsReleaseResult.noRelease; } } diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index a2ea9816ea0c..c7832e0da191 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -13,6 +13,8 @@ import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -427,6 +429,37 @@ void main() { }); test('can release newly created plugins', () async { + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': [], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': [], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -446,7 +479,6 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -463,10 +495,50 @@ void main() { test('can release newly created plugins, while there are existing plugins', () async { + const Map httpResponsePlugin0 = { + 'name': 'plugin0', + 'versions': ['0.0.1'], + }; + + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': [], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': [], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin0.json') { + return http.Response(json.encode(httpResponsePlugin0), 200); + } else if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Prepare an exiting plugin and tag it createFakePlugin('plugin0', packagesDir); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); + await gitDir.runCommand(['tag', 'plugin0-v0.0.1']); + // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -489,7 +561,6 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -505,6 +576,36 @@ void main() { }); test('can release newly created plugins, dry run', () async { + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': [], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': [], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -527,7 +628,6 @@ void main() { 'Checking local repo...', 'Local repo is ready!', '=============== DRY RUN ===============', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Tagging release plugin1-v0.0.1...', 'Pushing tag to upstream...', @@ -541,6 +641,37 @@ void main() { }); test('version change triggers releases.', () async { + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': [], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': [], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -558,7 +689,6 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -600,7 +730,6 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -619,6 +748,37 @@ void main() { test( 'delete package will not trigger publish but exit the command successfully.', () async { + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': [], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': [], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -636,7 +796,6 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Packages released: plugin1, plugin2', @@ -677,7 +836,6 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'The file at The pubspec file at ${pluginDir2.childFile('pubspec.yaml').path} does not exist. Publishing will not happen for plugin2.\nSafe to ignore if the package is deleted in this commit.\n', 'Packages released: plugin1', @@ -691,18 +849,48 @@ void main() { expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.2'); }); - test( - 'versions revert do not trigger releases. Also prints out warning message.', + test('Exiting versions do not trigger release, also prints out message.', () async { + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': ['0.0.2'], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': ['0.0.2'], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated - final Directory pluginDir1 = - createFakePlugin('plugin1', packagesDir, version: '0.0.2'); + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated - final Directory pluginDir2 = createFakePlugin( - 'plugin2', packagesDir.childDirectory('plugin2'), + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); + await gitDir.runCommand(['tag', 'plugin1-v0.0.2']); + await gitDir.runCommand(['tag', 'plugin2-v0.0.2']); // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -713,54 +901,64 @@ void main() { containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', - 'Getting existing tags...', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', + 'The version 0.0.2 of plugin1 has already been published', + 'skip.', + 'The version 0.0.2 of plugin2 has already been published', + 'skip.', 'Done!' ])); - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.2'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.2'); - processRunner.pushTagsArgs.clear(); - printedMessages.clear(); + expect(processRunner.pushTagsArgs, isEmpty); + }); - final List plugin1Pubspec = - pluginDir1.childFile('pubspec.yaml').readAsLinesSync(); - plugin1Pubspec[plugin1Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.1'; - pluginDir1 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin1Pubspec.join('\n')); - final List plugin2Pubspec = - pluginDir2.childFile('pubspec.yaml').readAsLinesSync(); - plugin2Pubspec[plugin2Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.1'; - pluginDir2 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin2Pubspec.join('\n')); - await gitDir.runCommand(['add', '-A']); - await gitDir - .runCommand(['commit', '-m', 'Update versions to 0.0.1']); + test( + 'Exiting versions do not trigger release, but fail if the tags do not exist.', + () async { + const Map httpResponsePlugin1 = { + 'name': 'plugin1', + 'versions': ['0.0.2'], + }; + + const Map httpResponsePlugin2 = { + 'name': 'plugin2', + 'versions': ['0.0.2'], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'plugin1.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } else if (request.url.pathSegments.last == 'plugin2.json') { + return http.Response(json.encode(httpResponsePlugin2), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - expect( - printedMessages, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Getting existing tags...', - 'The new version (0.0.1) is lower than the current version (0.0.2) for plugin1.\nThis git commit is a revert, no release is tagged.', - 'The new version (0.0.1) is lower than the current version (0.0.2) for plugin2.\nThis git commit is a revert, no release is tagged.', - 'Done!' - ])); + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + // Non-federated + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); + // federated + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'), + version: '0.0.2'); + await gitDir.runCommand(['add', '-A']); + await gitDir.runCommand(['commit', '-m', 'Add plugins']); + // Immediately return 0 when running `pub publish`. + processRunner.mockPublishCompleteCode = 0; + mockStdin.readLineOutput = 'y'; + await expectLater( + () => commandRunner.run( + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']), + throwsA(const TypeMatcher())); expect(processRunner.pushTagsArgs, isEmpty); }); From 6d3c299abedd9e9f9dd2dc3b4d8990cc19d3a486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Freitas?= Date: Thu, 24 Jun 2021 21:16:08 +0100 Subject: [PATCH 074/364] [image_picker] Updated pickImage and pickVideo docs to expose the possible errors that can be thrown (#4089) --- packages/image_picker/image_picker/CHANGELOG.md | 5 +++++ .../image_picker/lib/image_picker.dart | 16 ++++++++++++++++ packages/image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 7147c0f6f7de..428203b0327a 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,8 @@ + +## 0.8.1+1 + +* Expose errors thrown in `pickImage` and `pickVideo` docs. + ## 0.8.1 * Add a new method `getMultiImage` to allow picking multiple images on iOS 14 or higher diff --git a/packages/image_picker/image_picker/lib/image_picker.dart b/packages/image_picker/image_picker/lib/image_picker.dart index f4dee93ee1d6..3d08a38d9f6e 100755 --- a/packages/image_picker/image_picker/lib/image_picker.dart +++ b/packages/image_picker/image_picker/lib/image_picker.dart @@ -56,6 +56,11 @@ class ImagePicker { /// in this call. You can then call [getLostData] when your app relaunches to retrieve the lost data. /// /// See also [getMultiImage] to allow users to select multiple images at once. + /// + /// The method could throw [PlatformException] if the app does not have permission to access + /// the camera or photos gallery, no camera is available, plugin is already in use, + /// temporary file could not be created (iOS only), plugin activity could not + /// be allocated (Android only) or due to an unknown error. Future getImage({ required ImageSource source, double? maxWidth, @@ -90,6 +95,11 @@ class ImagePicker { /// image types such as JPEG and on Android PNG and WebP, too. If compression is not supported for the image that is picked, /// a warning message will be logged. /// + /// The method could throw [PlatformException] if the app does not have permission to access + /// the camera or photos gallery, no camera is available, plugin is already in use, + /// temporary file could not be created (iOS only), plugin activity could not + /// be allocated (Android only) or due to an unknown error. + /// /// See also [getImage] to allow users to only pick a single image. Future?> getMultiImage({ double? maxWidth, @@ -119,6 +129,12 @@ class ImagePicker { /// /// In Android, the MainActivity can be destroyed for various fo reasons. If that happens, the result will be lost /// in this call. You can then call [getLostData] when your app relaunches to retrieve the lost data. + /// + /// The method could throw [PlatformException] if the app does not have permission to access + /// the camera or photos gallery, no camera is available, plugin is already in use, + /// temporary file could not be created and video could not be cached (iOS only), + /// plugin activity could not be allocated (Android only) or due to an unknown error. + /// Future getVideo({ required ImageSource source, CameraDevice preferredCameraDevice = CameraDevice.rear, diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index b8aa9337a30c..e8fafb324e71 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.1 +version: 0.8.1+1 environment: sdk: ">=2.12.0 <3.0.0" From 152ae1f7b80d7d698ef35965d4c483c00f1b4b53 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Thu, 24 Jun 2021 13:21:06 -0700 Subject: [PATCH 075/364] Update .ci.yaml documentation link (#4090) --- .ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci.yaml b/.ci.yaml index 2f947ff38da1..92bfc040eecb 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -4,7 +4,7 @@ # for every commit. # # More information at: -# * https://github.com/flutter/cocoon/blob/master/scheduler/README.md +# * https://github.com/flutter/cocoon/blob/master/CI_YAML.md enabled_branches: - master From 09e2d5c2610aad8ea040dddc99ce2ecc2596cbb9 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 24 Jun 2021 14:11:05 -0700 Subject: [PATCH 076/364] [url_launcher] Fix test button check for iOS 15 (#4088) --- packages/url_launcher/url_launcher/CHANGELOG.md | 4 ++++ .../example/ios/RunnerUITests/URLLauncherUITests.m | 14 ++++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 697b7c7816dd..bbc5f2445a9e 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fix test button check for iOS 15. + ## 6.0.7 * Update the README to describe a workaround to the `Uri` query diff --git a/packages/url_launcher/url_launcher/example/ios/RunnerUITests/URLLauncherUITests.m b/packages/url_launcher/url_launcher/example/ios/RunnerUITests/URLLauncherUITests.m index f7ae5d9250da..18af3be9a1e5 100644 --- a/packages/url_launcher/url_launcher/example/ios/RunnerUITests/URLLauncherUITests.m +++ b/packages/url_launcher/url_launcher/example/ios/RunnerUITests/URLLauncherUITests.m @@ -27,19 +27,13 @@ - (void)testLaunch { ]; for (NSString* buttonName in buttonNames) { XCUIElement* button = app.buttons[buttonName]; - if (![button waitForExistenceWithTimeout:30.0]) { - os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); - XCTFail(@"Failed due to not able to find %@ button", buttonName); - } + XCTAssertTrue([button waitForExistenceWithTimeout:30.0]); XCTAssertEqual(app.webViews.count, 0); [button tap]; XCUIElement* webView = app.webViews.firstMatch; - if (![webView waitForExistenceWithTimeout:30.0]) { - os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); - XCTFail(@"Failed due to not able to find webview"); - } - XCTAssertTrue(app.buttons[@"ForwardButton"].exists); - XCTAssertTrue(app.buttons[@"ShareButton"].exists); + XCTAssertTrue([webView waitForExistenceWithTimeout:30.0]); + XCTAssertTrue([app.buttons[@"ForwardButton"] waitForExistenceWithTimeout:30.0]); + XCTAssertTrue(app.buttons[@"Share"].exists); XCTAssertTrue(app.buttons[@"OpenInSafariButton"].exists); [app.buttons[@"Done"] tap]; } From 6c5ececee89d5a7574cd71bbde12acd0d257b116 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 24 Jun 2021 17:01:05 -0700 Subject: [PATCH 077/364] Build all iOS example apps on current Flutter stable (#4101) --- .../ios/Runner.xcodeproj/project.pbxproj | 32 +----------- .../contents.xcworkspacedata | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 32 +----------- .../contents.xcworkspacedata | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 32 +----------- .../contents.xcworkspacedata | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 32 +----------- .../contents.xcworkspacedata | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 32 +----------- .../contents.xcworkspacedata | 2 +- .../contents.xcworkspacedata | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 51 ++++++++++++++++++- .../contents.xcworkspacedata | 2 +- .../contents.xcworkspacedata | 3 ++ 14 files changed, 65 insertions(+), 163 deletions(-) diff --git a/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj b/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj index ad48b12527b0..f994b369afe9 100644 --- a/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/battery/battery/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,10 +9,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -28,8 +24,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -41,14 +35,12 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 1E2CD29898079A0E658445A5 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 5F92487ECF695372E82D90C5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -63,8 +55,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, CC33A11108F15DB5F0C6C7AD /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -92,9 +82,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -159,7 +147,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 4096151B6BA12D6D4D7DD96A /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -229,22 +216,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; - }; - 4096151B6BA12D6D4D7DD96A /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -315,7 +287,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +343,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/packages/battery/battery/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/battery/battery/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/battery/battery/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/battery/battery/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj b/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj index b69d2cc24a6f..b653a1f3b889 100644 --- a/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,10 +9,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -28,8 +24,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -41,14 +35,12 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3173C764DD180BE02EB51E47 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 69D903F0A9A7C636EE803AF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -63,8 +55,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, EB0BA966000B5C35B13186D7 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -92,9 +82,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -159,7 +147,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 6A2F146AD353BE7A0C3E797E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -229,7 +216,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 3BAF367E8BACBC7576CEE653 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -249,21 +236,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 6A2F146AD353BE7A0C3E797E /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -315,7 +287,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +343,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/connectivity/connectivity/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj b/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj index 60f84800118f..ca599db5b7ac 100644 --- a/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,11 +9,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4C26954642C9965233939F98 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 17470FCDF9FA37CB94B63753 /* libPods-Runner.a */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -28,8 +24,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -41,13 +35,11 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 17470FCDF9FA37CB94B63753 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -63,8 +55,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, 4C26954642C9965233939F98 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,9 +74,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -159,7 +147,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 2C129809545EBEEB9253436A /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -217,21 +204,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2C129809545EBEEB9253436A /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -244,7 +216,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -315,7 +287,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +343,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/device_info/device_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj b/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj index dd979713357f..f21209190faa 100644 --- a/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/package_info/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,10 +9,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -28,8 +24,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -41,7 +35,6 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -49,7 +42,6 @@ 8F88DBCB0DD2793F05ADE394 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -63,8 +55,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, C824856099B606661DF36830 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,9 +74,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -159,7 +147,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 1B10719E4FA771B320770278 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -217,21 +204,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1B10719E4FA771B320770278 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -244,7 +216,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -315,7 +287,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +343,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/packages/package_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/package_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/package_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/package_info/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj b/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj index 2360a4aaf532..69cd37f9ab86 100644 --- a/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/sensors/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,10 +9,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -28,8 +24,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -40,7 +34,6 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 518BFCF6A33590E963FE1FA9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 65D7779632A59CFED1723B85 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -48,7 +41,6 @@ 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -63,8 +55,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, A5B646543530B300A487D9B1 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,9 +74,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -159,7 +147,6 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 2EB2E4FB0B576731DB30F0C4 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -217,21 +204,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2EB2E4FB0B576731DB30F0C4 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -244,7 +216,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 7B77DB2BA78582CC43C8E79F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -315,7 +287,6 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -372,7 +343,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/packages/sensors/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/sensors/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/sensors/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/sensors/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/share/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/share/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/share/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/share/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj index e0a688dfffad..98b6089f0d68 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 78A2B22AE5FB53474D0E7B48 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5304CE43F05781426D604828 /* libPods-Runner.a */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -30,9 +31,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 11F3C35054888B3724893A22 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1E5A9EB282D46A445314F9FD /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 2BADCFEAF6163E1D252C8765 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5304CE43F05781426D604828 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -51,12 +56,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A2B22AE5FB53474D0E7B48 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 53534922C743E29B902DE7D2 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5304CE43F05781426D604828 /* libPods-Runner.a */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -74,7 +88,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, + 9938C0E7E0C974A660788A69 /* Pods */, + 53534922C743E29B902DE7D2 /* Frameworks */, ); sourceTree = ""; }; @@ -110,6 +125,17 @@ name = "Supporting Files"; sourceTree = ""; }; + 9938C0E7E0C974A660788A69 /* Pods */ = { + isa = PBXGroup; + children = ( + 1E5A9EB282D46A445314F9FD /* Pods-Runner.debug.xcconfig */, + 2BADCFEAF6163E1D252C8765 /* Pods-Runner.release.xcconfig */, + 11F3C35054888B3724893A22 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -117,6 +143,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 77C985AE0A130001A78D36BE /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -194,6 +221,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 77C985AE0A130001A78D36BE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..919434a6254f 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16ed0f..21a3cc14c74e 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/packages/wifi_info_flutter/wifi_info_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + From 26e827845487ebec91a95b304f82f1952697efeb Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Thu, 24 Jun 2021 20:39:27 -0700 Subject: [PATCH 078/364] Add release status badge to README (#4102) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1dd6d80f2a11..2d8f4f502e17 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Flutter plugins [![Build Status](https://api.cirrus-ci.com/github/flutter/plugins.svg)](https://cirrus-ci.com/github/flutter/plugins/master) +[![Release Status](https://github.com/flutter/plugins/actions/workflows/release.yml/badge.svg)](https://github.com/flutter/plugins/actions/workflows/release.yml) This repo is a companion repo to the main [flutter repo](https://github.com/flutter/flutter). It contains the source code for From 97877524f50c32e16ab279ae88d07abd028c2494 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 25 Jun 2021 08:59:03 -0700 Subject: [PATCH 079/364] [flutter_plugin_tools] Migrate analyze to new base command (#4084) Switches `analyze` to the new base command that handles the boilerplate of looping over target packages. This will change the output format slightly, but shoudn't have any functional change. Updates tests to use runCapturingPrint so that test run output isn't mixed with command output. Part of https://github.com/flutter/flutter/issues/83413 --- script/tool/lib/src/analyze_command.dart | 74 ++++++++++++---------- script/tool/test/analyze_command_test.dart | 19 +++--- 2 files changed, 52 insertions(+), 41 deletions(-) diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 003f0bcda82d..3f6a2444ad9b 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -8,11 +8,13 @@ import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +const int _exitBadCustomAnalysisFile = 2; + /// A command to run Dart analysis on packages. -class AnalyzeCommand extends PluginCommand { +class AnalyzeCommand extends PackageLoopingCommand { /// Creates a analysis command instance. AnalyzeCommand( Directory packagesDir, { @@ -32,6 +34,8 @@ class AnalyzeCommand extends PluginCommand { static const String _analysisSdk = 'analysis-sdk'; + late String _dartBinaryPath; + @override final String name = 'analyze'; @@ -40,9 +44,10 @@ class AnalyzeCommand extends PluginCommand { 'This command requires "dart" and "flutter" to be in your path.'; @override - Future run() async { - print('Verifying analysis settings...'); + final bool hasLongOutput = false; + /// Checks that there are no unexpected analysis_options.yaml files. + void _validateAnalysisOptions() { final List files = packagesDir.listSync(recursive: true); for (final FileSystemEntity file in files) { if (file.basename != 'analysis_options.yaml' && @@ -59,18 +64,25 @@ class AnalyzeCommand extends PluginCommand { continue; } - print('Found an extra analysis_options.yaml in ${file.absolute.path}.'); - print( - 'If this was deliberate, pass the package to the analyze command with the --$_customAnalysisFlag flag and try again.'); - throw ToolExit(1); + printError( + 'Found an extra analysis_options.yaml in ${file.absolute.path}.'); + printError( + 'If this was deliberate, pass the package to the analyze command ' + 'with the --$_customAnalysisFlag flag and try again.'); + throw ToolExit(_exitBadCustomAnalysisFile); } + } + /// Ensures that the dependent packages have been fetched for all packages + /// (including their sub-packages) that will be analyzed. + Future _runPackagesGetOnTargetPackages() async { final List packageDirectories = await getPackages().toList(); final Set packagePaths = packageDirectories.map((Directory dir) => dir.path).toSet(); packageDirectories.removeWhere((Directory directory) { - // We remove the 'example' subdirectories - 'flutter pub get' automatically - // runs 'pub get' there as part of handling the parent directory. + // Remove the 'example' subdirectories; 'flutter packages get' + // automatically runs 'pub get' there as part of handling the parent + // directory. return directory.basename == 'example' && packagePaths.contains(directory.parent.path); }); @@ -78,33 +90,29 @@ class AnalyzeCommand extends PluginCommand { await processRunner.runAndStream('flutter', ['packages', 'get'], workingDir: package, exitOnError: true); } + } + + @override + Future initializeRun() async { + print('Verifying analysis settings...'); + _validateAnalysisOptions(); + + print('Fetching dependencies...'); + await _runPackagesGetOnTargetPackages(); // Use the Dart SDK override if one was passed in. final String? dartSdk = argResults![_analysisSdk] as String?; - final String dartBinary = - dartSdk == null ? 'dart' : p.join(dartSdk, 'bin', 'dart'); - - final List failingPackages = []; - final List pluginDirectories = await getPlugins().toList(); - for (final Directory package in pluginDirectories) { - final int exitCode = await processRunner.runAndStream( - dartBinary, ['analyze', '--fatal-infos'], - workingDir: package); - if (exitCode != 0) { - failingPackages.add(p.basename(package.path)); - } - } - - print('\n\n'); + _dartBinaryPath = dartSdk == null ? 'dart' : p.join(dartSdk, 'bin', 'dart'); + } - if (failingPackages.isNotEmpty) { - print('The following packages have analyzer errors (see above):'); - for (final String package in failingPackages) { - print(' * $package'); - } - throw ToolExit(1); + @override + Future> runForPackage(Directory package) async { + final int exitCode = await processRunner.runAndStream( + _dartBinaryPath, ['analyze', '--fatal-infos'], + workingDir: package); + if (exitCode != 0) { + return PackageLoopingCommand.failure; } - - print('No analyzer errors found!'); + return PackageLoopingCommand.success; } } diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 464aa1d91473..bdf9910f0b12 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -36,7 +36,7 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); processRunner.processToReturn = mockProcess; - await runner.run(['analyze']); + await runCapturingPrint(runner, ['analyze']); expect( processRunner.recordedCalls, @@ -58,7 +58,7 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); processRunner.processToReturn = mockProcess; - await runner.run(['analyze']); + await runCapturingPrint(runner, ['analyze']); expect( processRunner.recordedCalls, @@ -77,7 +77,7 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); processRunner.processToReturn = mockProcess; - await runner.run(['analyze']); + await runCapturingPrint(runner, ['analyze']); expect( processRunner.recordedCalls, @@ -99,7 +99,8 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); processRunner.processToReturn = mockProcess; - await runner.run(['analyze', '--analysis-sdk', 'foo/bar/baz']); + await runCapturingPrint( + runner, ['analyze', '--analysis-sdk', 'foo/bar/baz']); expect( processRunner.recordedCalls, @@ -123,7 +124,7 @@ void main() { createFakePlugin('foo', packagesDir, extraFiles: ['analysis_options.yaml']); - await expectLater(() => runner.run(['analyze']), + await expectLater(() => runCapturingPrint(runner, ['analyze']), throwsA(const TypeMatcher())); }); @@ -131,7 +132,7 @@ void main() { createFakePlugin('foo', packagesDir, extraFiles: ['.analysis_options']); - await expectLater(() => runner.run(['analyze']), + await expectLater(() => runCapturingPrint(runner, ['analyze']), throwsA(const TypeMatcher())); }); @@ -142,7 +143,8 @@ void main() { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); processRunner.processToReturn = mockProcess; - await runner.run(['analyze', '--custom-analysis', 'foo']); + await runCapturingPrint( + runner, ['analyze', '--custom-analysis', 'foo']); expect( processRunner.recordedCalls, @@ -164,7 +166,8 @@ void main() { processRunner.processToReturn = mockProcess; await expectLater( - () => runner.run(['analyze', '--custom-analysis', '']), + () => runCapturingPrint( + runner, ['analyze', '--custom-analysis', '']), throwsA(const TypeMatcher())); }); }); From bc4ac59421bb68e5adaa29720be5d0db4f89cc13 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 25 Jun 2021 09:59:23 -0700 Subject: [PATCH 080/364] Don't install cocoapods; use the version in the image (#4104) --- .cirrus.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.cirrus.yml b/.cirrus.yml index 5e685c974c9d..e4c804f05656 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -30,7 +30,6 @@ macos_template: &MACOS_TEMPLATE use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' osx_instance: image: big-sur-xcode-12.5 - cocoapod_install_script: sudo gem install cocoapods # Light-workload Linux tasks. # These use default machines, with fewer CPUs, to reduce pressure on the From 714339b3f0718f01476ddcf45f5167b2c9502c07 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 25 Jun 2021 14:40:52 -0700 Subject: [PATCH 081/364] Migrate command, add failure test, remove skip (#4106) Switches `podspec` to the new base command that handles the boilerplate of looping over target packages. Includes test improvements: - Adds failure tests; previously no failure cases were covered. - Captures output using the standard mechanism instead of using a custom `_print`, simplifying the command code. Also removes `--skip`, which is redundant with `--exclude`. Part of flutter/flutter#83413 --- script/tool/CHANGELOG.md | 3 +- .../tool/lib/src/common/plugin_command.dart | 10 ++- .../tool/lib/src/lint_podspecs_command.dart | 65 ++++++--------- .../tool/test/lint_podspecs_command_test.dart | 79 ++++++++++++------- 4 files changed, 87 insertions(+), 70 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 3e2ec3f4c991..1b6cf2a44718 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -7,7 +7,8 @@ - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. - The tooling now runs in strong null-safe mode. - `publish plugins` check against pub.dev to determine if a release should happen. -- Modified the output format of `pubspec-check` and `xctest` +- Modified the output format of many commands +- Removed `podspec`'s `--skip` in favor of `--ignore` using the new structure. ## 0.2.0 diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 4c095858e45a..e3ee109dd0cf 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -250,10 +250,16 @@ abstract class PluginCommand extends Command { /// Returns the files contained, recursively, within the plugins /// involved in this command execution. Stream getFiles() { - return getPlugins().asyncExpand((Directory folder) => folder + return getPlugins() + .asyncExpand((Directory folder) => getFilesForPackage(folder)); + } + + /// Returns the files contained, recursively, within [package]. + Stream getFilesForPackage(Directory package) { + return package .list(recursive: true, followLinks: false) .where((FileSystemEntity entity) => entity is File) - .cast()); + .cast(); } /// Returns whether the specified entity is a directory containing a diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 5e86d2be40b8..2b4beeb92a1f 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -10,29 +10,26 @@ import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +const int _exitUnsupportedPlatform = 2; + /// Lint the CocoaPod podspecs and run unit tests. /// /// See https://guides.cocoapods.org/terminal/commands.html#pod_lib_lint. -class LintPodspecsCommand extends PluginCommand { +class LintPodspecsCommand extends PackageLoopingCommand { /// Creates an instance of the linter command. LintPodspecsCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), - Print print = print, }) : _platform = platform, - _print = print, super(packagesDir, processRunner: processRunner) { - argParser.addMultiOption('skip', - help: - 'Skip all linting for podspecs with this basename (example: federated plugins with placeholder podspecs)', - valueHelp: 'podspec_file_name'); argParser.addMultiOption('ignore-warnings', help: - 'Do not pass --allow-warnings flag to "pod lib lint" for podspecs with this basename (example: plugins with known warnings)', + 'Do not pass --allow-warnings flag to "pod lib lint" for podspecs ' + 'with this basename (example: plugins with known warnings)', valueHelp: 'podspec_file_name'); } @@ -49,13 +46,11 @@ class LintPodspecsCommand extends PluginCommand { final Platform _platform; - final Print _print; - @override - Future run() async { + Future initializeRun() async { if (!_platform.isMacOS) { - _print('Detected platform is not macOS, skipping podspec lint'); - return; + printError('This command is only supported on macOS'); + throw ToolExit(_exitUnsupportedPlatform); } await processRunner.run( @@ -65,32 +60,24 @@ class LintPodspecsCommand extends PluginCommand { exitOnError: true, logOnError: true, ); + } - _print('Starting podspec lint test'); - - final List failingPlugins = []; - for (final File podspec in await _podspecsToLint()) { + @override + Future> runForPackage(Directory package) async { + final List errors = []; + for (final File podspec in await _podspecsToLint(package)) { if (!await _lintPodspec(podspec)) { - failingPlugins.add(p.basenameWithoutExtension(podspec.path)); - } - } - - _print('\n\n'); - if (failingPlugins.isNotEmpty) { - _print('The following plugins have podspec errors (see above):'); - for (final String plugin in failingPlugins) { - _print(' * $plugin'); + errors.add(p.basename(podspec.path)); } - throw ToolExit(1); } + return errors; } - Future> _podspecsToLint() async { - final List podspecs = await getFiles().where((File entity) { + Future> _podspecsToLint(Directory package) async { + final List podspecs = + await getFilesForPackage(package).where((File entity) { final String filePath = entity.path; - return p.extension(filePath) == '.podspec' && - !getStringListArg('skip') - .contains(p.basenameWithoutExtension(filePath)); + return p.extension(filePath) == '.podspec'; }).toList(); podspecs.sort( @@ -103,19 +90,19 @@ class LintPodspecsCommand extends PluginCommand { final String podspecPath = podspec.path; final String podspecBasename = p.basename(podspecPath); - _print('Linting $podspecBasename'); + print('Linting $podspecBasename'); // Lint plugin as framework (use_frameworks!). final ProcessResult frameworkResult = await _runPodLint(podspecPath, libraryLint: true); - _print(frameworkResult.stdout); - _print(frameworkResult.stderr); + print(frameworkResult.stdout); + print(frameworkResult.stderr); // Lint plugin as library. final ProcessResult libraryResult = await _runPodLint(podspecPath, libraryLint: false); - _print(libraryResult.stdout); - _print(libraryResult.stderr); + print(libraryResult.stdout); + print(libraryResult.stderr); return frameworkResult.exitCode == 0 && libraryResult.exitCode == 0; } @@ -135,7 +122,7 @@ class LintPodspecsCommand extends PluginCommand { if (libraryLint) '--use-libraries' ]; - _print('Running "pod ${arguments.join(' ')}"'); + print('Running "pod ${arguments.join(' ')}"'); return processRunner.run('pod', arguments, workingDir: packagesDir, stdoutEncoding: utf8, stderrEncoding: utf8); } diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index d86c9145fc19..a6a5502913a0 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -5,6 +5,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/lint_podspecs_command.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -19,20 +20,17 @@ void main() { late CommandRunner runner; late MockPlatform mockPlatform; late RecordingProcessRunner processRunner; - late List printedMessages; setUp(() { fileSystem = MemoryFileSystem(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); - printedMessages = []; mockPlatform = MockPlatform(isMacOS: true); processRunner = RecordingProcessRunner(); final LintPodspecsCommand command = LintPodspecsCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, - print: (Object? message) => printedMessages.add(message.toString()), ); runner = @@ -47,14 +45,26 @@ void main() { test('only runs on macOS', () async { createFakePlugin('plugin1', packagesDir, extraFiles: ['plugin1.podspec']); - mockPlatform.isMacOS = false; - await runner.run(['podspecs']); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['podspecs'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( processRunner.recordedCalls, equals([]), ); + + expect( + output, + containsAllInOrder( + [contains('only supported on macOS')], + )); }); test('runs pod lib lint on a podspec', () async { @@ -70,7 +80,8 @@ void main() { processRunner.resultStdout = 'Foo'; processRunner.resultStderr = 'Bar'; - await runner.run(['podspecs']); + final List output = + await runCapturingPrint(runner, ['podspecs']); expect( processRunner.recordedCalls, @@ -102,33 +113,17 @@ void main() { ]), ); - expect(printedMessages, contains('Linting plugin1.podspec')); - expect(printedMessages, contains('Foo')); - expect(printedMessages, contains('Bar')); - }); - - test('skips podspecs with known issues', () async { - createFakePlugin('plugin1', packagesDir, - extraFiles: ['plugin1.podspec']); - createFakePlugin('plugin2', packagesDir, - extraFiles: ['plugin2.podspec']); - - await runner - .run(['podspecs', '--skip=plugin1', '--skip=plugin2']); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall('which', const ['pod'], packagesDir.path), - ]), - ); + expect(output, contains('Linting plugin1.podspec')); + expect(output, contains('Foo')); + expect(output, contains('Bar')); }); test('allow warnings for podspecs with known warnings', () async { final Directory plugin1Dir = createFakePlugin('plugin1', packagesDir, extraFiles: ['plugin1.podspec']); - await runner.run(['podspecs', '--ignore-warnings=plugin1']); + final List output = await runCapturingPrint( + runner, ['podspecs', '--ignore-warnings=plugin1']); expect( processRunner.recordedCalls, @@ -162,7 +157,35 @@ void main() { ]), ); - expect(printedMessages, contains('Linting plugin1.podspec')); + expect(output, contains('Linting plugin1.podspec')); + }); + + test('fails if linting fails', () async { + createFakePlugin('plugin1', packagesDir, + extraFiles: ['plugin1.podspec']); + + // Simulate failure from `pod`. + final MockProcess mockDriveProcess = MockProcess(); + mockDriveProcess.exitCodeCompleter.complete(1); + processRunner.processToReturn = mockDriveProcess; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['podspecs'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder( + [ + contains('The following packages had errors:'), + contains('plugin1:\n' + ' plugin1.podspec') + ], + )); }); }); } From cb535ef3b678ed7d85bc6794c939e9ffb2d0ba20 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Sun, 27 Jun 2021 18:30:27 -0700 Subject: [PATCH 082/364] [flutter_plugin_tools] release 0.3.0 (#4109) --- script/tool/CHANGELOG.md | 2 +- script/tool/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 1b6cf2a44718..94514e38103a 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,4 +1,4 @@ -## NEXT +## 0.3.0 - Add a --build-id flag to `firebase-test-lab` instead of hard-coding the use of `CIRRUS_BUILD_ID`. `CIRRUS_BUILD_ID` is the default value for that flag, for backward diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 5d2200abcdb0..6273fe9bf277 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.2.0 +version: 0.3.0 dependencies: args: ^2.1.0 From fde14716b52785ae862c6606dbf9d3ab84d75825 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Mon, 28 Jun 2021 13:11:03 +0200 Subject: [PATCH 083/364] [in_app_purchase] Add support for SKPaymentQueueDelegate and showPriceConsentIfNeeded (#4085) --- .../in_app_purchase_ios/CHANGELOG.md | 8 +- .../in_app_purchase_ios/example/ios/Podfile | 4 +- .../ios/Runner.xcodeproj/project.pbxproj | 54 ++++---- .../example/ios/Runner/Configuration.storekit | 10 +- .../RunnerTests/FIAPPaymentQueueDeleteTests.m | 120 ++++++++++++++++++ .../RunnerTests/InAppPurchasePluginTests.m | 77 +++++++++++ .../example/ios/RunnerTests/Stubs.h | 5 + .../example/ios/RunnerTests/Stubs.m | 14 ++ .../example/ios/RunnerTests/TranslatorTests.m | 30 +++++ .../lib/example_payment_queue_delegate.dart | 23 ++++ .../in_app_purchase_ios/example/lib/main.dart | 14 +- .../ios/Classes/FIAObjectTranslator.h | 18 +++ .../ios/Classes/FIAObjectTranslator.m | 27 ++++ .../ios/Classes/FIAPPaymentQueueDelegate.h | 16 +++ .../ios/Classes/FIAPPaymentQueueDelegate.m | 78 ++++++++++++ .../ios/Classes/FIAPaymentQueueHandler.h | 12 ++ .../ios/Classes/FIAPaymentQueueHandler.m | 9 ++ .../ios/Classes/InAppPurchasePlugin.m | 90 ++++++++++--- .../in_app_purchase_ios/lib/src/channel.dart | 5 + ...in_app_purchase_ios_platform_addition.dart | 24 ++++ .../sk_payment_queue_delegate_wrapper.dart | 39 ++++++ .../sk_payment_queue_wrapper.dart | 81 +++++++++++- .../sk_storefront_wrapper.dart | 65 ++++++++++ .../sk_storefront_wrapper.g.dart | 21 +++ .../lib/store_kit_wrappers.dart | 2 + .../in_app_purchase_ios/pubspec.yaml | 2 +- .../sk_methodchannel_apis_test.dart | 31 +++++ .../sk_payment_queue_delegate_api_test.dart | 109 ++++++++++++++++ 28 files changed, 930 insertions(+), 58 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m create mode 100644 packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart create mode 100644 packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h create mode 100644 packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m create mode 100644 packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 4b2d8ce1dc24..c4c4eb05cecd 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,7 +1,11 @@ +## 0.1.1 + +* Added support to register a `SKPaymentQueueDelegateWrapper` and handle changes to active subscriptions accordingly (see also Store Kit's [SKPaymentQueueDelegate](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc)). + ## 0.1.0+2 -* Changed the iOS payment queue handler in such a way that it only adds a listener to the SKPaymentQueue when there - is a listener to the Dart purchaseStream. +* Changed the iOS payment queue handler in such a way that it only adds a listener to the `SKPaymentQueue` when there + is a listener to the Dart `purchaseStream`. ## 0.1.0+1 diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile index ae8750242a6e..5200b9fa5045 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Podfile @@ -29,12 +29,12 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - + target 'RunnerTests' do inherit! :search_paths # Matches in_app_purchase test_spec dependency. - pod 'OCMock','3.5' + pod 'OCMock', '~> 3.6' end end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj index 590b07f0d385..61a5da696986 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 688DE35021F2A5A100EA2684 /* TranslatorTests.m */; }; 6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */; }; 6896B34C21EEB4B800D37AEF /* Stubs.m in Sources */ = {isa = PBXBuildFile; fileRef = 6896B34B21EEB4B800D37AEF /* Stubs.m */; }; + 7E34217B7715B1918134647A /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 18D02AB334F1C07BB9A4374A /* libPods-RunnerTests.a */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -20,7 +21,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A5279298219369C600FF69E6 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5279297219369C600FF69E6 /* StoreKit.framework */; }; A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */; }; - AB7252348F077C046D6617D3 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */; }; + F67646F82681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */; }; F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F78AF3132342BC89008449C7 /* PaymentQueueTests.m */; }; /* End PBXBuildFile section */ @@ -48,14 +49,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 10B860DFD91A1DF639D7BE1D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 1630769A874F9381BC761FE1 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 18D02AB334F1C07BB9A4374A /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 2550EB3A5A3E749A54ADCA2D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 688DE35021F2A5A100EA2684 /* TranslatorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TranslatorTests.m; sourceTree = ""; }; 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ProductRequestHandlerTests.m; sourceTree = ""; }; 6896B34A21EEB4B800D37AEF /* Stubs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Stubs.h; sourceTree = ""; }; @@ -71,11 +71,13 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D681E092EB0D20D652F69FC /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; A5279297219369C600FF69E6 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; A59001A421E69658004A3E5E /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A59001A621E69658004A3E5E /* InAppPurchasePluginTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InAppPurchasePluginTests.m; sourceTree = ""; }; A59001A821E69658004A3E5E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E4F9651425A612301059769C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIAPPaymentQueueDeleteTests.m; sourceTree = ""; }; F6E5D5F926131C4800C68BED /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; F78AF3132342BC89008449C7 /* PaymentQueueTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PaymentQueueTests.m; sourceTree = ""; }; /* End PBXFileReference section */ @@ -94,7 +96,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AB7252348F077C046D6617D3 /* libPods-RunnerTests.a in Frameworks */, + 7E34217B7715B1918134647A /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,8 +108,8 @@ children = ( E4F9651425A612301059769C /* Pods-Runner.debug.xcconfig */, 2550EB3A5A3E749A54ADCA2D /* Pods-Runner.release.xcconfig */, - 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */, - 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */, + 9D681E092EB0D20D652F69FC /* Pods-RunnerTests.debug.xcconfig */, + 10B860DFD91A1DF639D7BE1D /* Pods-RunnerTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -187,6 +189,7 @@ 6896B34521E9363700D37AEF /* ProductRequestHandlerTests.m */, F78AF3132342BC89008449C7 /* PaymentQueueTests.m */, 688DE35021F2A5A100EA2684 /* TranslatorTests.m */, + F67646F72681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m */, ); path = RunnerTests; sourceTree = ""; @@ -196,7 +199,7 @@ children = ( A5279297219369C600FF69E6 /* StoreKit.framework */, 1630769A874F9381BC761FE1 /* libPods-Runner.a */, - 630DD71BB3F145A22B1DE15D /* libPods-RunnerTests.a */, + 18D02AB334F1C07BB9A4374A /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -229,7 +232,7 @@ isa = PBXNativeTarget; buildConfigurationList = A59001AD21E69658004A3E5E /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 321E2F5767F55B0A360AA77E /* [CP] Check Pods Manifest.lock */, + 95C7A5986B77A8DF76F6DF3A /* [CP] Check Pods Manifest.lock */, A59001A021E69658004A3E5E /* Sources */, A59001A121E69658004A3E5E /* Frameworks */, A59001A221E69658004A3E5E /* Resources */, @@ -310,41 +313,41 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 321E2F5767F55B0A360AA77E /* [CP] Check Pods Manifest.lock */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "Thin Binary"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 95C7A5986B77A8DF76F6DF3A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -400,6 +403,7 @@ buildActionMask = 2147483647; files = ( F78AF3142342BC89008449C7 /* PaymentQueueTests.m in Sources */, + F67646F82681D9A80048C2EA /* FIAPPaymentQueueDeleteTests.m in Sources */, 6896B34621E9363700D37AEF /* ProductRequestHandlerTests.m in Sources */, 688DE35121F2A5A100EA2684 /* TranslatorTests.m in Sources */, A59001A721E69658004A3E5E /* InAppPurchasePluginTests.m in Sources */, @@ -593,7 +597,7 @@ }; A59001AB21E69658004A3E5E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 194D4829A79EF6C7426B39F7 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 9D681E092EB0D20D652F69FC /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -616,7 +620,7 @@ }; A59001AC21E69658004A3E5E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 027D04BC80EACAAB3B5232B8 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 10B860DFD91A1DF639D7BE1D /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit index 4958a846e67d..b98fefb68a95 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner/Configuration.storekit @@ -1,4 +1,8 @@ { + "identifier" : "6073E9A3", + "nonRenewingSubscriptions" : [ + + ], "products" : [ { "displayPrice" : "0.99", @@ -46,7 +50,7 @@ "adHocOffers" : [ ], - "displayPrice" : "3.99", + "displayPrice" : "4.99", "familyShareable" : false, "groupNumber" : 1, "internalID" : "922EB597", @@ -59,7 +63,7 @@ } ], "productID" : "subscription_silver", - "recurringSubscriptionPeriod" : "P1M", + "recurringSubscriptionPeriod" : "P1W", "referenceName" : "subscription_silver", "subscriptionGroupID" : "D0FEE8D8", "type" : "RecurringSubscription" @@ -91,6 +95,6 @@ ], "version" : { "major" : 1, - "minor" : 0 + "minor" : 1 } } diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m new file mode 100644 index 000000000000..810e1fafe11a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m @@ -0,0 +1,120 @@ +// Copyright 2013 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 +#import +#import "FIAObjectTranslator.h" +#import "FIAPaymentQueueHandler.h" +#import "Stubs.h" + +@import in_app_purchase_ios; + +API_AVAILABLE(ios(13.0)) +@interface FIAPPaymentQueueDelegateTests : XCTestCase + +@property(strong, nonatomic) FlutterMethodChannel *channel; +@property(strong, nonatomic) SKPaymentTransaction *transaction; +@property(strong, nonatomic) SKStorefront *storefront; + +@end + +@implementation FIAPPaymentQueueDelegateTests + +- (void)setUp { + self.channel = OCMClassMock(FlutterMethodChannel.class); + + NSDictionary *transactionMap = @{ + @"transactionIdentifier" : [NSNull null], + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + @"originalTransaction" : [NSNull null], + }; + self.transaction = [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + + NSDictionary *storefrontMap = @{ + @"countryCode" : @"USA", + @"identifier" : @"unique_identifier", + }; + self.storefront = [[SKStorefrontStub alloc] initWithMap:storefrontMap]; +} + +- (void)tearDown { + self.channel = nil; +} + +- (void)testShouldContinueTransaction { + if (@available(iOS 13.0, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel + invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:self.storefront + andSKPaymentTransaction:self.transaction] + result:([OCMArg invokeBlockWithArgs:[NSNumber numberWithBool:NO], nil])]); + + BOOL shouldContinue = [delegate paymentQueue:OCMClassMock(SKPaymentQueue.class) + shouldContinueTransaction:self.transaction + inStorefront:self.storefront]; + + XCTAssertFalse(shouldContinue); + } +} + +- (void)testShouldContinueTransaction_should_default_to_yes { + if (@available(iOS 13.0, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:self.storefront + andSKPaymentTransaction:self.transaction] + result:[OCMArg any]]); + + BOOL shouldContinue = [delegate paymentQueue:OCMClassMock(SKPaymentQueue.class) + shouldContinueTransaction:self.transaction + inStorefront:self.storefront]; + + XCTAssertTrue(shouldContinue); + } +} + +- (void)testShouldShowPriceConsentIfNeeded { + if (@available(iOS 13.4, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel + invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:([OCMArg invokeBlockWithArgs:[NSNumber numberWithBool:NO], nil])]); + + BOOL shouldShow = + [delegate paymentQueueShouldShowPriceConsent:OCMClassMock(SKPaymentQueue.class)]; + + XCTAssertFalse(shouldShow); + } +} + +- (void)testShouldShowPriceConsentIfNeeded_should_default_to_yes { + if (@available(iOS 13.4, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:[OCMArg any]]); + + BOOL shouldShow = + [delegate paymentQueueShouldShowPriceConsent:OCMClassMock(SKPaymentQueue.class)]; + + XCTAssertTrue(shouldShow); + } +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m index 241ea0d5cb0d..045abcdea922 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m @@ -343,4 +343,81 @@ - (void)testStartAndStopObservingPaymentQueue { XCTAssertNil(queue.observer); } +- (void)testRegisterPaymentQueueDelegate { + if (@available(iOS 13, *)) { + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue registerDelegate]" + arguments:nil]; + + self.plugin.paymentQueueHandler = + [[FIAPaymentQueueHandler alloc] initWithQueue:[SKPaymentQueueStub new] + transactionsUpdated:nil + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:nil + updatedDownloads:nil]; + + // Verify the delegate is nil before we register one. + XCTAssertNil(self.plugin.paymentQueueHandler.delegate); + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + + // Verify the delegate is not nil after we registered one. + XCTAssertNotNil(self.plugin.paymentQueueHandler.delegate); + } +} + +- (void)testRemovePaymentQueueDelegate { + if (@available(iOS 13, *)) { + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue removeDelegate]" + arguments:nil]; + + self.plugin.paymentQueueHandler = + [[FIAPaymentQueueHandler alloc] initWithQueue:[SKPaymentQueueStub new] + transactionsUpdated:nil + transactionRemoved:nil + restoreTransactionFailed:nil + restoreCompletedTransactionsFinished:nil + shouldAddStorePayment:nil + updatedDownloads:nil]; + self.plugin.paymentQueueHandler.delegate = OCMProtocolMock(@protocol(SKPaymentQueueDelegate)); + + // Verify the delegate is not nil before removing it. + XCTAssertNotNil(self.plugin.paymentQueueHandler.delegate); + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + + // Verify the delegate is nill after removing it. + XCTAssertNil(self.plugin.paymentQueueHandler.delegate); + } +} + +- (void)testShowPriceConsentIfNeeded { + FlutterMethodCall* call = + [FlutterMethodCall methodCallWithMethodName:@"-[SKPaymentQueue showPriceConsentIfNeeded]" + arguments:nil]; + + FIAPaymentQueueHandler* mockQueueHandler = OCMClassMock(FIAPaymentQueueHandler.class); + self.plugin.paymentQueueHandler = mockQueueHandler; + + [self.plugin handleMethodCall:call + result:^(id r){ + }]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wpartial-availability" + if (@available(iOS 13.4, *)) { + OCMVerify(times(1), [mockQueueHandler showPriceConsentIfNeeded]); + } else { + OCMVerify(never(), [mockQueueHandler showPriceConsentIfNeeded]); + } +#pragma clang diagnostic pop +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h index 687118febb29..7b6842da4c77 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h @@ -60,4 +60,9 @@ API_AVAILABLE(ios(11.2), macos(10.13.2)) - (instancetype)initWithFailureError:(NSError *)error; @end +API_AVAILABLE(ios(13.0), macos(10.15)) +@interface SKStorefrontStub : SKStorefront +- (instancetype)initWithMap:(NSDictionary *)map; +@end + NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m index 8af326a48722..a57831c61da5 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m @@ -290,3 +290,17 @@ - (void)start { } @end + +@implementation SKStorefrontStub + +- (instancetype)initWithMap:(NSDictionary *)map { + self = [super init]; + if (self) { + // Set stub values + [self setValue:map[@"countryCode"] forKey:@"countryCode"]; + [self setValue:map[@"identifier"] forKey:@"identifier"]; + } + return self; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m index 385d29140e49..42c51b846857 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m @@ -17,6 +17,8 @@ @interface TranslatorTest : XCTestCase @property(strong, nonatomic) NSDictionary *transactionMap; @property(strong, nonatomic) NSDictionary *errorMap; @property(strong, nonatomic) NSDictionary *localeMap; +@property(strong, nonatomic) NSDictionary *storefrontMap; +@property(strong, nonatomic) NSDictionary *storefrontAndPaymentTransactionMap; @end @@ -84,6 +86,15 @@ - (void)setUp { @"key" : @"value", } }; + self.storefrontMap = @{ + @"countryCode" : @"USA", + @"identifier" : @"unique_identifier", + }; + + self.storefrontAndPaymentTransactionMap = @{ + @"storefront" : self.storefrontMap, + @"transaction" : self.transactionMap, + }; } - (void)testSKProductSubscriptionPeriodStubToMap { @@ -144,4 +155,23 @@ - (void)testLocaleToMap { } } +- (void)testSKStorefrontToMap { + if (@available(iOS 13.0, *)) { + SKStorefront *storefront = [[SKStorefrontStub alloc] initWithMap:self.storefrontMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKStorefront:storefront]; + XCTAssertEqualObjects(map, self.storefrontMap); + } +} + +- (void)testSKStorefrontAndSKPaymentTransactionToMap { + if (@available(iOS 13.0, *)) { + SKStorefront *storefront = [[SKStorefrontStub alloc] initWithMap:self.storefrontMap]; + SKPaymentTransaction *transaction = + [[SKPaymentTransactionStub alloc] initWithMap:self.transactionMap]; + NSDictionary *map = [FIAObjectTranslator getMapFromSKStorefront:storefront + andSKPaymentTransaction:transaction]; + XCTAssertEqualObjects(map, self.storefrontAndPaymentTransactionMap); + } +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart new file mode 100644 index 000000000000..dfebdf9cdf98 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart @@ -0,0 +1,23 @@ +// Copyright 2013 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 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +/// Example implementation of the +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart index 5452f5a0ee83..19884745bce8 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_ios_example/example_payment_queue_delegate.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'consumable_store.dart'; @@ -40,6 +41,9 @@ class _MyApp extends StatefulWidget { class _MyAppState extends State<_MyApp> { final InAppPurchaseIosPlatform _iapIosPlatform = InAppPurchasePlatform.instance as InAppPurchaseIosPlatform; + final InAppPurchaseIosPlatformAddition _iapIosPlatformAddition = + InAppPurchasePlatformAddition.instance + as InAppPurchaseIosPlatformAddition; late StreamSubscription> _subscription; List _notFoundIds = []; List _products = []; @@ -61,6 +65,10 @@ class _MyAppState extends State<_MyApp> { }, onError: (error) { // handle error here. }); + + // Register the example payment queue delegate + _iapIosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); + initStoreInfo(); super.initState(); } @@ -241,7 +249,11 @@ class _MyAppState extends State<_MyApp> { productDetails.description, ), trailing: previousPurchase != null - ? Icon(Icons.check) + ? IconButton( + onPressed: () { + _iapIosPlatformAddition.showPriceConsentIfNeeded(); + }, + icon: Icon(Icons.upgrade)) : TextButton( child: Text(productDetails.price), style: TextButton.styleFrom( diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h index 2d0187e88aed..95a5edc245dc 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.h @@ -9,26 +9,44 @@ NS_ASSUME_NONNULL_BEGIN @interface FIAObjectTranslator : NSObject +// Converts an instance of SKProduct into a dictionary. + (NSDictionary *)getMapFromSKProduct:(SKProduct *)product; +// Converts an instance of SKProductSubscriptionPeriod into a dictionary. + (NSDictionary *)getMapFromSKProductSubscriptionPeriod:(SKProductSubscriptionPeriod *)period API_AVAILABLE(ios(11.2)); +// Converts an instance of SKProductDiscount into a dictionary. + (NSDictionary *)getMapFromSKProductDiscount:(SKProductDiscount *)discount API_AVAILABLE(ios(11.2)); +// Converts an instance of SKProductsResponse into a dictionary. + (NSDictionary *)getMapFromSKProductsResponse:(SKProductsResponse *)productResponse; +// Converts an instance of SKPayment into a dictionary. + (NSDictionary *)getMapFromSKPayment:(SKPayment *)payment; +// Converts an instance of NSLocale into a dictionary. + (NSDictionary *)getMapFromNSLocale:(NSLocale *)locale; +// Creates an instance of the SKMutablePayment class based on the supplied dictionary. + (SKMutablePayment *)getSKMutablePaymentFromMap:(NSDictionary *)map; +// Converts an instance of SKPaymentTransaction into a dictionary. + (NSDictionary *)getMapFromSKPaymentTransaction:(SKPaymentTransaction *)transaction; +// Converts an instance of NSError into a dictionary. + (NSDictionary *)getMapFromNSError:(NSError *)error; +// Converts an instance of SKStorefront into a dictionary. ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront + API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); + +// Converts the supplied instances of SKStorefront and SKPaymentTransaction into a dictionary. ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront + andSKPaymentTransaction:(SKPaymentTransaction *)transaction + API_AVAILABLE(ios(13), macos(10.15), watchos(6.2)); + @end ; diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m index 5d6e0a244a96..30b0b812da15 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m @@ -169,4 +169,31 @@ + (NSDictionary *)getMapFromNSError:(NSError *)error { return @{@"code" : @(error.code), @"domain" : error.domain ?: @"", @"userInfo" : userInfo}; } ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront { + if (!storefront) { + return nil; + } + + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"countryCode" : storefront.countryCode, + @"identifier" : storefront.identifier + }]; + + return map; +} + ++ (NSDictionary *)getMapFromSKStorefront:(SKStorefront *)storefront + andSKPaymentTransaction:(SKPaymentTransaction *)transaction { + if (!storefront || !transaction) { + return nil; + } + + NSMutableDictionary *map = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"storefront" : [FIAObjectTranslator getMapFromSKStorefront:storefront], + @"transaction" : [FIAObjectTranslator getMapFromSKPaymentTransaction:transaction] + }]; + + return map; +} + @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h new file mode 100644 index 000000000000..a6c91fa9e6b6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h @@ -0,0 +1,16 @@ +// Copyright 2013 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 +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +API_AVAILABLE(ios(13)) +@interface FIAPPaymentQueueDelegate : NSObject +- (id)initWithMethodChannel:(FlutterMethodChannel *)methodChannel; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m new file mode 100644 index 000000000000..1056086030a5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m @@ -0,0 +1,78 @@ +// Copyright 2013 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 "FIAPPaymentQueueDelegate.h" +#import "FIAObjectTranslator.h" + +@interface FIAPPaymentQueueDelegate () + +@property(strong, nonatomic, readonly) FlutterMethodChannel *callbackChannel; + +@end + +@implementation FIAPPaymentQueueDelegate + +- (id)initWithMethodChannel:(FlutterMethodChannel *)methodChannel { + self = [super init]; + if (self) { + _callbackChannel = methodChannel; + } + + return self; +} + +- (BOOL)paymentQueue:(SKPaymentQueue *)paymentQueue + shouldContinueTransaction:(SKPaymentTransaction *)transaction + inStorefront:(SKStorefront *)newStorefront { + // Default return value for this method is true (see + // https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc) + __block BOOL shouldContinue = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [self.callbackChannel invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:newStorefront + andSKPaymentTransaction:transaction] + result:^(id _Nullable result) { + // When result is a valid instance of NSNumber use it to determine + // if the transaction should continue. Otherwise use the default + // value. + if (result && [result isKindOfClass:[NSNumber class]]) { + shouldContinue = [(NSNumber *)result boolValue]; + } + + dispatch_semaphore_signal(semaphore); + }]; + + // The client should respond within 1 second otherwise continue + // with default value. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + + return shouldContinue; +} + +- (BOOL)paymentQueueShouldShowPriceConsent:(SKPaymentQueue *)paymentQueue { + // Default return value for this method is true (see + // https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc) + __block BOOL shouldShowPriceConsent = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [self.callbackChannel invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:^(id _Nullable result) { + // When result is a valid instance of NSNumber use it to determine + // if the transaction should continue. Otherwise use the default + // value. + if (result && [result isKindOfClass:[NSNumber class]]) { + shouldShowPriceConsent = [(NSNumber *)result boolValue]; + } + + dispatch_semaphore_signal(semaphore); + }]; + + // The client should respond within 1 second otherwise continue + // with default value. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + + return shouldShowPriceConsent; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h index 30865b2c3598..8019831d6355 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.h @@ -18,6 +18,9 @@ typedef void (^UpdatedDownloads)(NSArray *downloads); @interface FIAPaymentQueueHandler : NSObject +@property(NS_NONATOMIC_IOSONLY, weak, nullable) id delegate API_AVAILABLE( + ios(13.0), macos(10.15), watchos(6.2)); + - (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue transactionsUpdated:(nullable TransactionsUpdated)transactionsUpdated transactionRemoved:(nullable TransactionsRemoved)transactionsRemoved @@ -43,6 +46,15 @@ typedef void (^UpdatedDownloads)(NSArray *downloads); // @return whether "addPayment" was successful. - (BOOL)addPayment:(SKPayment *)payment; +// Displays the price consent sheet. +// +// The price consent sheet is only displayed when the following +// it true: +// - You have increased the price of the subscription in App Store Connect. +// - The subscriber has not yet responded to a price consent query. +// Otherwise the method has no effect. +- (void)showPriceConsentIfNeeded API_AVAILABLE(ios(13.4)); + @end NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m index 20ccbc5adb48..21667954cf8d 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPaymentQueueHandler.m @@ -3,6 +3,7 @@ // found in the LICENSE file. #import "FIAPaymentQueueHandler.h" +#import "FIAPPaymentQueueDelegate.h" @interface FIAPaymentQueueHandler () @@ -36,6 +37,10 @@ - (instancetype)initWithQueue:(nonnull SKPaymentQueue *)queue _paymentQueueRestoreCompletedTransactionsFinished = restoreCompletedTransactionsFinished; _shouldAddStorePayment = shouldAddStorePayment; _updatedDownloads = updatedDownloads; + + if (@available(iOS 13.0, macOS 10.15, *)) { + queue.delegate = self.delegate; + } } return self; } @@ -78,6 +83,10 @@ - (void)presentCodeRedemptionSheet { } } +- (void)showPriceConsentIfNeeded { + [self.queue showPriceConsentIfNeeded]; +} + #pragma mark - observing // Sent when the transaction array has changed (additions or state changes). Client should check diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m index 8a998d9f4300..c0db38e5cfe0 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m @@ -5,6 +5,7 @@ #import "InAppPurchasePlugin.h" #import #import "FIAObjectTranslator.h" +#import "FIAPPaymentQueueDelegate.h" #import "FIAPReceiptManager.h" #import "FIAPRequestHandler.h" #import "FIAPaymentQueueHandler.h" @@ -19,13 +20,19 @@ @interface InAppPurchasePlugin () // for purchase. @property(strong, nonatomic, readonly) NSMutableDictionary *productsCache; -// Call back channel to dart used for when a listener function is triggered. -@property(strong, nonatomic, readonly) FlutterMethodChannel *callbackChannel; +// Callback channel to dart used for when a function from the transaction observer is triggered. +@property(strong, nonatomic, readonly) FlutterMethodChannel *transactionObserverCallbackChannel; + +// Callback channel to dart used for when a function from the payment queue delegate is triggered. +@property(strong, nonatomic, readonly) FlutterMethodChannel *paymentQueueDelegateCallbackChannel; + @property(strong, nonatomic, readonly) NSObject *registry; @property(strong, nonatomic, readonly) NSObject *messenger; @property(strong, nonatomic, readonly) NSObject *registrar; @property(strong, nonatomic, readonly) FIAPReceiptManager *receiptManager; +@property(strong, nonatomic, readonly) + FIAPPaymentQueueDelegate *paymentQueueDelegate API_AVAILABLE(ios(13)); @end @@ -73,7 +80,8 @@ - (instancetype)initWithRegistrar:(NSObject *)registrar updatedDownloads:^void(NSArray *_Nonnull downloads) { [weakSelf updatedDownloads:downloads]; }]; - _callbackChannel = + + _transactionObserverCallbackChannel = [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/in_app_purchase" binaryMessenger:[registrar messenger]]; return self; @@ -100,9 +108,15 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"-[InAppPurchasePlugin refreshReceipt:result:]" isEqualToString:call.method]) { [self refreshReceipt:call result:result]; } else if ([@"-[SKPaymentQueue startObservingTransactionQueue]" isEqualToString:call.method]) { - [_paymentQueueHandler startObservingPaymentQueue]; + [self startObservingPaymentQueue:result]; } else if ([@"-[SKPaymentQueue stopObservingTransactionQueue]" isEqualToString:call.method]) { - [_paymentQueueHandler stopObservingPaymentQueue]; + [self stopObservingPaymentQueue:result]; + } else if ([@"-[SKPaymentQueue registerDelegate]" isEqualToString:call.method]) { + [self registerPaymentQueueDelegate:result]; + } else if ([@"-[SKPaymentQueue removeDelegate]" isEqualToString:call.method]) { + [self removePaymentQueueDelegate:result]; + } else if ([@"-[SKPaymentQueue showPriceConsentIfNeeded]" isEqualToString:call.method]) { + [self showPriceConsentIfNeeded:result]; } else { result(FlutterMethodNotImplemented); } @@ -301,14 +315,53 @@ - (void)refreshReceipt:(FlutterMethodCall *)call result:(FlutterResult)result { }]; } -#pragma mark - delegates: +- (void)startObservingPaymentQueue:(FlutterResult)result { + [_paymentQueueHandler startObservingPaymentQueue]; + result(nil); +} + +- (void)stopObservingPaymentQueue:(FlutterResult)result { + [_paymentQueueHandler stopObservingPaymentQueue]; + result(nil); +} + +- (void)registerPaymentQueueDelegate:(FlutterResult)result { + if (@available(iOS 13.0, *)) { + _paymentQueueDelegateCallbackChannel = [FlutterMethodChannel + methodChannelWithName:@"plugins.flutter.io/in_app_purchase_payment_queue_delegate" + binaryMessenger:_messenger]; + + _paymentQueueDelegate = [[FIAPPaymentQueueDelegate alloc] + initWithMethodChannel:_paymentQueueDelegateCallbackChannel]; + _paymentQueueHandler.delegate = _paymentQueueDelegate; + } + result(nil); +} + +- (void)removePaymentQueueDelegate:(FlutterResult)result { + if (@available(iOS 13.0, *)) { + _paymentQueueHandler.delegate = nil; + } + _paymentQueueDelegate = nil; + _paymentQueueDelegateCallbackChannel = nil; + result(nil); +} + +- (void)showPriceConsentIfNeeded:(FlutterResult)result { + if (@available(iOS 13.4, *)) { + [_paymentQueueHandler showPriceConsentIfNeeded]; + } + result(nil); +} + +#pragma mark - transaction observer: - (void)handleTransactionsUpdated:(NSArray *)transactions { NSMutableArray *maps = [NSMutableArray new]; for (SKPaymentTransaction *transaction in transactions) { [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; } - [self.callbackChannel invokeMethod:@"updatedTransactions" arguments:maps]; + [self.transactionObserverCallbackChannel invokeMethod:@"updatedTransactions" arguments:maps]; } - (void)handleTransactionsRemoved:(NSArray *)transactions { @@ -316,17 +369,19 @@ - (void)handleTransactionsRemoved:(NSArray *)transaction for (SKPaymentTransaction *transaction in transactions) { [maps addObject:[FIAObjectTranslator getMapFromSKPaymentTransaction:transaction]]; } - [self.callbackChannel invokeMethod:@"removedTransactions" arguments:maps]; + [self.transactionObserverCallbackChannel invokeMethod:@"removedTransactions" arguments:maps]; } - (void)handleTransactionRestoreFailed:(NSError *)error { - [self.callbackChannel invokeMethod:@"restoreCompletedTransactionsFailed" - arguments:[FIAObjectTranslator getMapFromNSError:error]]; + [self.transactionObserverCallbackChannel + invokeMethod:@"restoreCompletedTransactionsFailed" + arguments:[FIAObjectTranslator getMapFromNSError:error]]; } - (void)restoreCompletedTransactionsFinished { - [self.callbackChannel invokeMethod:@"paymentQueueRestoreCompletedTransactionsFinished" - arguments:nil]; + [self.transactionObserverCallbackChannel + invokeMethod:@"paymentQueueRestoreCompletedTransactionsFinished" + arguments:nil]; } - (void)updatedDownloads:(NSArray *)downloads { @@ -338,11 +393,12 @@ - (BOOL)shouldAddStorePayment:(SKPayment *)payment product:(SKProduct *)product // have a interception method that deciding if the payment should be processed (implemented by the // programmer). [self.productsCache setObject:product forKey:product.productIdentifier]; - [self.callbackChannel invokeMethod:@"shouldAddStorePayment" - arguments:@{ - @"payment" : [FIAObjectTranslator getMapFromSKPayment:payment], - @"product" : [FIAObjectTranslator getMapFromSKProduct:product] - }]; + [self.transactionObserverCallbackChannel + invokeMethod:@"shouldAddStorePayment" + arguments:@{ + @"payment" : [FIAObjectTranslator getMapFromSKPayment:payment], + @"product" : [FIAObjectTranslator getMapFromSKProduct:product] + }]; return NO; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart index f8ab4d48be7e..d045dab448e8 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/channel.dart @@ -7,3 +7,8 @@ import 'package:flutter/services.dart'; /// Method channel for the plugin's platform<-->Dart calls. const MethodChannel channel = MethodChannel('plugins.flutter.io/in_app_purchase'); + +/// Method channel used to deliver the payment queue delegate system calls to +/// Dart. +const MethodChannel paymentQueueDelegateChannel = + MethodChannel('plugins.flutter.io/in_app_purchase_payment_queue_delegate'); diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart index 0c7b2de860b6..bcc4ddf48200 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart @@ -30,4 +30,28 @@ class InAppPurchaseIosPlatformAddition extends InAppPurchasePlatformAddition { serverVerificationData: receipt, source: kIAPSource); } + + /// Sets an implementation of the [SKPaymentQueueDelegateWrapper]. + /// + /// The [SKPaymentQueueDelegateWrapper] can be used to inform iOS how to + /// finish transactions when the storefront changes or if the price consent + /// sheet should be displayed when the price of a subscription has changed. If + /// no delegate is registered iOS will fallback to it's default configuration. + /// See the documentation on StoreKite's [`-[SKPaymentQueue delegate:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc). + /// + /// When set to `null` the payment queue delegate will be removed and the + /// default behaviour will apply (see [documentation](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc)). + Future setDelegate(SKPaymentQueueDelegateWrapper? delegate) => + SKPaymentQueueWrapper().setDelegate(delegate); + + /// Shows the price consent sheet if the user has not yet responded to a + /// subscription price change. + /// + /// Use this function when you have registered a [SKPaymentQueueDelegateWrapper] + /// (using the [setDelegate] method) and returned `false` when the + /// `SKPaymentQueueDelegateWrapper.shouldShowPriceConsent()` method was called. + /// + /// See documentation of StoreKit's [`-[SKPaymentQueue showPriceConsentIfNeeded]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded?language=objc). + Future showPriceConsentIfNeeded() => + SKPaymentQueueWrapper().showPriceConsentIfNeeded(); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart new file mode 100644 index 000000000000..2759a296389b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart @@ -0,0 +1,39 @@ +// Copyright 2013 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 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +/// A wrapper around +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +/// +/// The [SKPaymentQueueDelegateWrapper] is only available on iOS 13 and higher. +/// Using the delegate on older iOS version will be ignored. +abstract class SKPaymentQueueDelegateWrapper { + /// Called by the system to check whether the transaction should continue if + /// the device's App Store storefront has changed during a transaction. + /// + /// - Return `true` if the transaction should continue within the updated + /// storefront (default behaviour). + /// - Return `false` if the transaction should be cancelled. In this case the + /// transaction will fail with the error [SKErrorStoreProductNotAvailable](https://developer.apple.com/documentation/storekit/skerrorcode/skerrorstoreproductnotavailable?language=objc). + /// + /// See the documentation in StoreKit's [`[-SKPaymentQueueDelegate shouldContinueTransaction]`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3242935-paymentqueue?language=objc). + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, + SKStorefrontWrapper storefront, + ) => + true; + + /// Called by the system to check whether to immediately show the price + /// consent form. + /// + /// The default return value is `true`. This will inform the system to display + /// the price consent sheet when the subscription price has been changed in + /// App Store Connect and the subscriber has not yet taken action. See the + /// documentation in StoreKit's [`[-SKPaymentQueueDelegate shouldShowPriceConsent:]`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc). + bool shouldShowPriceConsent() => true; +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart index fe5f14ba44a5..c39ad9efddd7 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart @@ -6,12 +6,15 @@ import 'dart:async'; import 'dart:ui' show hashValues; import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import '../channel.dart'; import '../in_app_purchase_ios_platform.dart'; +import 'sk_payment_queue_delegate_wrapper.dart'; import 'sk_payment_transaction_wrappers.dart'; import 'sk_product_wrapper.dart'; @@ -40,6 +43,7 @@ class SKPaymentQueueWrapper { static final SKPaymentQueueWrapper _singleton = SKPaymentQueueWrapper._(); + SKPaymentQueueDelegateWrapper? _paymentQueueDelegate; SKTransactionObserverWrapper? _observer; /// Calls [`-[SKPaymentQueue transactions]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506026-transactions?language=objc) @@ -70,18 +74,39 @@ class SKPaymentQueueWrapper { /// /// Call this method when the first listener is subscribed to the /// [InAppPurchaseIosPlatform.purchaseStream]. - Future startObservingTransactionQueue() async => - await channel.invokeListMethod( - '-[SKPaymentQueue startObservingTransactionQueue]'); + Future startObservingTransactionQueue() => channel + .invokeMethod('-[SKPaymentQueue startObservingTransactionQueue]'); /// Instructs the iOS implementation to remove the transaction observer and /// stop listening to it. /// /// Call this when there are no longer any listeners subscribed to the /// [InAppPurchaseIosPlatform.purchaseStream]. - Future stopObservingTransactionQueue() async => - await channel.invokeListMethod( - '-[SKPaymentQueue stopObservingTransactionQueue]'); + Future stopObservingTransactionQueue() => channel + .invokeMethod('-[SKPaymentQueue stopObservingTransactionQueue]'); + + /// Sets an implementation of the [SKPaymentQueueDelegateWrapper]. + /// + /// The [SKPaymentQueueDelegateWrapper] can be used to inform iOS how to + /// finish transactions when the storefront changes or if the price consent + /// sheet should be displayed when the price of a subscription has changed. If + /// no delegate is registered iOS will fallback to it's default configuration. + /// See the documentation on StoreKite's [`-[SKPaymentQueue delegate:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc). + /// + /// When set to `null` the payment queue delegate will be removed and the + /// default behaviour will apply (see [documentation](https://developer.apple.com/documentation/storekit/skpaymentqueue/3182429-delegate?language=objc)). + Future setDelegate(SKPaymentQueueDelegateWrapper? delegate) async { + if (delegate == null) { + await channel.invokeMethod('-[SKPaymentQueue removeDelegate]'); + paymentQueueDelegateChannel.setMethodCallHandler(null); + } else { + await channel.invokeMethod('-[SKPaymentQueue registerDelegate]'); + paymentQueueDelegateChannel + .setMethodCallHandler(handlePaymentQueueDelegateCallbacks); + } + + _paymentQueueDelegate = delegate; + } /// Posts a payment to the queue. /// @@ -170,8 +195,21 @@ class SKPaymentQueueWrapper { '-[InAppPurchasePlugin presentCodeRedemptionSheet:result:]'); } + /// Shows the price consent sheet if the user has not yet responded to a + /// subscription price change. + /// + /// Use this function when you have registered a [SKPaymentQueueDelegateWrapper] + /// (using the [setDelegate] method) and returned `false` when the + /// `SKPaymentQueueDelegateWrapper.shouldShowPriceConsent()` method was called. + /// + /// See documentation of StoreKit's [`-[SKPaymentQueue showPriceConsentIfNeeded]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/3521327-showpriceconsentifneeded?language=objc). + Future showPriceConsentIfNeeded() async { + await channel + .invokeMethod('-[SKPaymentQueue showPriceConsentIfNeeded]'); + } + // Triage a method channel call from the platform and triggers the correct observer method. - Future _handleObserverCallbacks(MethodCall call) async { + Future _handleObserverCallbacks(MethodCall call) async { assert(_observer != null, '[in_app_purchase]: (Fatal)The observer has not been set but we received a purchase transaction notification. Please ensure the observer has been set using `setTransactionObserver`. Make sure the observer is added right at the App Launch.'); final SKTransactionObserverWrapper observer = _observer!; @@ -235,6 +273,35 @@ class SKPaymentQueueWrapper { Map.castFrom(map)); }).toList(); } + + /// Triage a method channel call from the platform and triggers the correct + /// payment queue delegate method. + /// + /// This method is public for testing purposes only and should not be used + /// outside this class. + @visibleForTesting + Future handlePaymentQueueDelegateCallbacks(MethodCall call) async { + assert(_paymentQueueDelegate != null, + '[in_app_purchase]: (Fatal)The payment queue delegate has not been set but we received a payment queue notification. Please ensure the payment queue has been set using `setDelegate`.'); + + final SKPaymentQueueDelegateWrapper delegate = _paymentQueueDelegate!; + switch (call.method) { + case 'shouldContinueTransaction': + final SKPaymentTransactionWrapper transaction = + SKPaymentTransactionWrapper.fromJson(call.arguments['transaction']); + final SKStorefrontWrapper storefront = + SKStorefrontWrapper.fromJson(call.arguments['storefront']); + return delegate.shouldContinueTransaction(transaction, storefront); + case 'shouldShowPriceConsent': + return delegate.shouldShowPriceConsent(); + default: + break; + } + throw PlatformException( + code: 'no_such_callback', + message: + 'Did not recognize the payment queue delegate callback ${call.method}.'); + } } /// Dart wrapper around StoreKit's diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart new file mode 100644 index 000000000000..934fdea355e3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart @@ -0,0 +1,65 @@ +// Copyright 2013 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:ui' show hashValues; + +import 'package:json_annotation/json_annotation.dart'; + +part 'sk_storefront_wrapper.g.dart'; + +/// Contains the location and unique identifier of an Apple App Store storefront. +/// +/// Dart wrapper around StoreKit's +/// [SKStorefront](https://developer.apple.com/documentation/storekit/skstorefront?language=objc). +@JsonSerializable() +class SKStorefrontWrapper { + /// Creates a new [SKStorefrontWrapper] with the provided information. + SKStorefrontWrapper({ + required this.countryCode, + required this.identifier, + }); + + /// Constructs an instance of the [SKStorefrontWrapper] from a key value map + /// of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKStorefrontWrapper.fromJson(Map map) { + return _$SKStorefrontWrapperFromJson(map); + } + + /// The three-letter code representing the country or region associated with + /// the App Store storefront. + final String countryCode; + + /// A value defined by Apple that uniquely identifies an App Store storefront. + final String identifier; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKStorefrontWrapper typedOther = other as SKStorefrontWrapper; + return typedOther.countryCode == countryCode && + typedOther.identifier == identifier; + } + + @override + int get hashCode => hashValues( + this.countryCode, + this.identifier, + ); + + @override + String toString() => _$SKStorefrontWrapperToJson(this).toString(); + + /// Converts the instance to a key value map which can be used to serialize + /// to JSON format. + Map toMap() => _$SKStorefrontWrapperToJson(this); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart new file mode 100644 index 000000000000..f75cfc5711e8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sk_storefront_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SKStorefrontWrapper _$SKStorefrontWrapperFromJson(Map json) { + return SKStorefrontWrapper( + countryCode: json['countryCode'] as String, + identifier: json['identifier'] as String, + ); +} + +Map _$SKStorefrontWrapperToJson( + SKStorefrontWrapper instance) => + { + 'countryCode': instance.countryCode, + 'identifier': instance.identifier, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart index b687d238083c..09eb1acb8420 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/store_kit_wrappers.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart'; export 'src/store_kit_wrappers/sk_payment_queue_wrapper.dart'; export 'src/store_kit_wrappers/sk_payment_transaction_wrappers.dart'; export 'src/store_kit_wrappers/sk_product_wrapper.dart'; export 'src/store_kit_wrappers/sk_receipt_manager.dart'; export 'src/store_kit_wrappers/sk_request_maker.dart'; +export 'src/store_kit_wrappers/sk_storefront_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 5b9e3892d40d..00929d9c024b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.0+2 +version: 0.1.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index edb50aeb62a0..6a01fe4caecb 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -145,6 +145,20 @@ void main() { await SKPaymentQueueWrapper().stopObservingTransactionQueue(); expect(fakeIOSPlatform.queueIsActive, false); }); + + test('setDelegate should call methodChannel', () async { + expect(fakeIOSPlatform.isPaymentQueueDelegateRegistered, false); + await SKPaymentQueueWrapper().setDelegate(TestPaymentQueueDelegate()); + expect(fakeIOSPlatform.isPaymentQueueDelegateRegistered, true); + await SKPaymentQueueWrapper().setDelegate(null); + expect(fakeIOSPlatform.isPaymentQueueDelegateRegistered, false); + }); + + test('showPriceConsentIfNeeded should call methodChannel', () async { + expect(fakeIOSPlatform.showPriceConsentIfNeeded, false); + await SKPaymentQueueWrapper().showPriceConsentIfNeeded(); + expect(fakeIOSPlatform.showPriceConsentIfNeeded, true); + }); }); group('Code Redemption Sheet', () { @@ -178,6 +192,12 @@ class FakeIOSPlatform { // present Code Redemption bool presentCodeRedemption = false; + // show price consent sheet + bool showPriceConsentIfNeeded = false; + + // indicate if the payment queue delegate is registered + bool isPaymentQueueDelegateRegistered = false; + // Listen to purchase updates bool? queueIsActive; @@ -230,11 +250,22 @@ class FakeIOSPlatform { case '-[SKPaymentQueue stopObservingTransactionQueue]': queueIsActive = false; return Future.sync(() {}); + case '-[SKPaymentQueue registerDelegate]': + isPaymentQueueDelegateRegistered = true; + return Future.sync(() {}); + case '-[SKPaymentQueue removeDelegate]': + isPaymentQueueDelegateRegistered = false; + return Future.sync(() {}); + case '-[SKPaymentQueue showPriceConsentIfNeeded]': + showPriceConsentIfNeeded = true; + return Future.sync(() {}); } return Future.error('method not mocked'); } } +class TestPaymentQueueDelegate extends SKPaymentQueueDelegateWrapper {} + class TestPaymentTransactionObserver extends SKTransactionObserverWrapper { void updatedTransactions( {required List transactions}) {} diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart new file mode 100644 index 000000000000..b61411dfffa4 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart @@ -0,0 +1,109 @@ +// Copyright 2013 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 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/src/channel.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + + setUpAll(() { + SystemChannels.platform + .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); + }); + + test( + 'handlePaymentQueueDelegateCallbacks should call SKPaymentQueueDelegateWrapper.shouldContinueTransaction', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentQueueDelegate testDelegate = TestPaymentQueueDelegate(); + await queue.setDelegate(testDelegate); + + final Map arguments = { + 'storefront': { + 'countryCode': 'USA', + 'identifier': 'unique_identifier', + }, + 'transaction': { + 'payment': { + 'productIdentifier': 'product_identifier', + } + }, + }; + + final result = await queue.handlePaymentQueueDelegateCallbacks( + MethodCall('shouldContinueTransaction', arguments), + ); + + expect(result, false); + expect( + testDelegate.log, + { + equals('shouldContinueTransaction'), + }, + ); + }); + + test( + 'handlePaymentQueueDelegateCallbacks should call SKPaymentQueueDelegateWrapper.shouldShowPriceConsent', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentQueueDelegate testDelegate = TestPaymentQueueDelegate(); + await queue.setDelegate(testDelegate); + + final result = await queue.handlePaymentQueueDelegateCallbacks( + MethodCall('shouldShowPriceConsent'), + ); + + expect(result, false); + expect( + testDelegate.log, + { + equals('shouldShowPriceConsent'), + }, + ); + }); +} + +class TestPaymentQueueDelegate extends SKPaymentQueueDelegateWrapper { + final List log = []; + + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + log.add('shouldContinueTransaction'); + return false; + } + + @override + bool shouldShowPriceConsent() { + log.add('shouldShowPriceConsent'); + return false; + } +} + +class FakeIOSPlatform { + FakeIOSPlatform() { + channel.setMockMethodCallHandler(onMethodCall); + } + + // indicate if the payment queue delegate is registered + bool isPaymentQueueDelegateRegistered = false; + + Future onMethodCall(MethodCall call) { + switch (call.method) { + case '-[SKPaymentQueue registerDelegate]': + isPaymentQueueDelegateRegistered = true; + return Future.sync(() {}); + case '-[SKPaymentQueue removeDelegate]': + isPaymentQueueDelegateRegistered = false; + return Future.sync(() {}); + } + return Future.error('method not mocked'); + } +} From 8af8001ab96b273db599e399b84f949bee5fd860 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Mon, 28 Jun 2021 09:06:03 -0700 Subject: [PATCH 084/364] [flutter_plugin_tools] ignore flutter_plugin_tools when publishing (#4110) --- .../tool/lib/src/publish_plugin_command.dart | 8 +++ .../test/publish_plugin_command_test.dart | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 622a1a3cb133..18b6ff0ed742 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -290,6 +290,14 @@ Safe to ignore if the package is deleted in this commit. } final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + + if (pubspec.name == 'flutter_plugin_tools') { + // Ignore flutter_plugin_tools package when running publishing through flutter_plugin_tools. + // TODO(cyanglaz): Make the tool also auto publish flutter_plugin_tools package. + // https://github.com/flutter/flutter/issues/85430 + return _CheckNeedsReleaseResult.noRelease; + } + if (pubspec.publishTo == 'none') { return _CheckNeedsReleaseResult.noRelease; } diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index c7832e0da191..f060cd2fbfd8 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -995,6 +995,57 @@ void main() { ])); expect(processRunner.pushTagsArgs, isEmpty); }); + + test('Do not release flutter_plugin_tools', () async { + const Map httpResponsePlugin1 = { + 'name': 'flutter_plugin_tools', + 'versions': [], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'flutter_plugin_tools.json') { + return http.Response(json.encode(httpResponsePlugin1), 200); + } + return http.Response('', 500); + }); + final PublishPluginCommand command = PublishPluginCommand(packagesDir, + processRunner: processRunner, + print: (Object? message) => printedMessages.add(message.toString()), + stdinput: mockStdin, + httpClient: mockClient, + gitDir: gitDir); + + commandRunner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + commandRunner.addCommand(command); + + final Directory flutterPluginTools = + createFakePlugin('flutter_plugin_tools', packagesDir); + await gitDir.runCommand(['add', '-A']); + await gitDir.runCommand(['commit', '-m', 'Add plugins']); + // Immediately return 0 when running `pub publish`. + processRunner.mockPublishCompleteCode = 0; + mockStdin.readLineOutput = 'y'; + await commandRunner + .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( + printedMessages, + containsAllInOrder([ + 'Checking local repo...', + 'Local repo is ready!', + 'Done!' + ])); + expect( + printedMessages.contains( + 'Running `pub publish ` in ${flutterPluginTools.path}...\n', + ), + isFalse); + expect(processRunner.pushTagsArgs, isEmpty); + processRunner.pushTagsArgs.clear(); + printedMessages.clear(); + }); }); } From a02b8f2703ec56f07b728e43c07fc8cd93fd70e1 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 28 Jun 2021 11:26:34 -0700 Subject: [PATCH 085/364] [flutter_plugin_tools] Migrate java-test to new base command (#4105) Switches `java-test` to the new base command that handles the boilerplate of looping over target packages. Includes test improvements: - Adds failure tests; previously no failure cases were covered. - Captures output so test output isn't spammed with command output. Part of flutter/flutter#83413 --- script/tool/lib/src/java_test_command.dart | 55 +++++---------- script/tool/test/java_test_command_test.dart | 70 +++++++++++++++++++- 2 files changed, 84 insertions(+), 41 deletions(-) diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart index d7e453b6ad74..77b8aa70a6e4 100644 --- a/script/tool/lib/src/java_test_command.dart +++ b/script/tool/lib/src/java_test_command.dart @@ -6,17 +6,19 @@ import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; /// A command to run the Java tests of Android plugins. -class JavaTestCommand extends PluginCommand { +class JavaTestCommand extends PackageLoopingCommand { /// Creates an instance of the test runner. JavaTestCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), }) : super(packagesDir, processRunner: processRunner); + static const String _gradleWrapper = 'gradlew'; + @override final String name = 'java-test'; @@ -25,12 +27,10 @@ class JavaTestCommand extends PluginCommand { 'Building the apks of the example apps is required before executing this' 'command.'; - static const String _gradleWrapper = 'gradlew'; - @override - Future run() async { - final Stream examplesWithTests = getExamples().where( - (Directory d) => + Future> runForPackage(Directory package) async { + final Iterable examplesWithTests = getExamplesForPlugin(package) + .where((Directory d) => isFlutterPackage(d) && (d .childDirectory('android') @@ -44,18 +44,17 @@ class JavaTestCommand extends PluginCommand { .childDirectory('test') .existsSync())); - final List failingPackages = []; - final List missingFlutterBuild = []; - await for (final Directory example in examplesWithTests) { - final String packageName = - p.relative(example.path, from: packagesDir.path); - print('\nRUNNING JAVA TESTS for $packageName'); + final List errors = []; + for (final Directory example in examplesWithTests) { + final String exampleName = p.relative(example.path, from: package.path); + print('\nRUNNING JAVA TESTS for $exampleName'); final Directory androidDirectory = example.childDirectory('android'); if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { - print('ERROR: Run "flutter build apk" on example app of $packageName' + printError('ERROR: Run "flutter build apk" on $exampleName, or run ' + 'this tool\'s "build-examples --apk" command, ' 'before executing tests.'); - missingFlutterBuild.add(packageName); + errors.add('$exampleName has not been built.'); continue; } @@ -64,31 +63,9 @@ class JavaTestCommand extends PluginCommand { ['testDebugUnitTest', '--info'], workingDir: androidDirectory); if (exitCode != 0) { - failingPackages.add(packageName); - } - } - - print('\n\n'); - if (failingPackages.isNotEmpty) { - print( - 'The Java tests for the following packages are failing (see above for' - 'details):'); - for (final String package in failingPackages) { - print(' * $package'); - } - } - if (missingFlutterBuild.isNotEmpty) { - print('Run "pub global run flutter_plugin_tools build-examples --apk" on' - 'the following packages before executing tests again:'); - for (final String package in missingFlutterBuild) { - print(' * $package'); + errors.add('$exampleName tests failed.'); } } - - if (failingPackages.isNotEmpty || missingFlutterBuild.isNotEmpty) { - throw ToolExit(1); - } - - print('All Java tests successful!'); + return errors; } } diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index fc80961462c7..894a5c3fce70 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_plugin_tools/src/java_test_command.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; void main() { @@ -45,7 +46,7 @@ void main() { ], ); - await runner.run(['java-test']); + await runCapturingPrint(runner, ['java-test']); expect( processRunner.recordedCalls, @@ -72,7 +73,7 @@ void main() { ], ); - await runner.run(['java-test']); + await runCapturingPrint(runner, ['java-test']); expect( processRunner.recordedCalls, @@ -85,5 +86,70 @@ void main() { ]), ); }); + + test('fails when the app needs to be built', () async { + createFakePlugin( + 'plugin1', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/app/src/test/example_test.java', + ], + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['java-test'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('ERROR: Run "flutter build apk" on example'), + contains('plugin1:\n' + ' example has not been built.') + ]), + ); + }); + + test('fails when a test fails', () async { + createFakePlugin( + 'plugin1', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + ], + ); + + // Simulate failure from `gradlew`. + final MockProcess mockDriveProcess = MockProcess(); + mockDriveProcess.exitCodeCompleter.complete(1); + processRunner.processToReturn = mockDriveProcess; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['java-test'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('plugin1:\n' + ' example tests failed.') + ]), + ); + }); }); } From 62e83808ecb795a5b0497cb5686565b8ee433df5 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 28 Jun 2021 13:21:12 -0700 Subject: [PATCH 086/364] Split some Cirrus script steps (#4112) It is much easier to use the results UI for Cirrus tasks when each major portion of the script is its own script section, since that causes it to be displayed separately (with its own timing, logs, etc.) This has already been done for many tasks; this applies it to some that were missing it. --- .cirrus.yml | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index e4c804f05656..45f944283134 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -50,14 +50,12 @@ task: - cd script/tool - CIRRUS_BUILD_ID=null pub run test - name: publishable - script: - - ./script/tool_runner.sh version-check - - ./script/tool_runner.sh publish-check + version_check_script: ./script/tool_runner.sh version-check + publish_check_script: ./script/tool_runner.sh publish-check - name: format format_script: ./script/tool_runner.sh format --fail-on-change pubspec_script: ./script/tool_runner.sh pubspec-check - license_script: - - dart $PLUGIN_TOOL license-check + license_script: dart $PLUGIN_TOOL license-check - name: test env: matrix: @@ -139,27 +137,32 @@ task: CHANNEL: "stable" MAPS_API_KEY: ENCRYPTED[596a9f6bca436694625ac50851dc5da6b4d34cba8025f7db5bc9465142e8cd44e15f69e3507787753accebfc4910d550] GCLOUD_FIREBASE_TESTLAB_KEY: ENCRYPTED[07586610af1fdfc894e5969f70ef2458341b9b7e9c3b7c4225a663b4a48732b7208a4d91c3b7d45305a6b55fa2a37fc4] - script: + build_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. - # See: https://github.com/flutter/flutter/issues/24935 - # This is a temporary workaround until we figure how to properly configure - # a UTF8 locale on Cirrus (or until the Gradle bug is fixed). - # TODO(amirh): Set the locale to UTF8. - - echo "$CIRRUS_CHANGE_MESSAGE" > /tmp/cirrus_change_message.txt - - echo "$CIRRUS_COMMIT_MESSAGE" > /tmp/cirrus_commit_message.txt + # TODO(stuartmorgan): See https://github.com/flutter/flutter/issues/24935 - export CIRRUS_CHANGE_MESSAGE="" - export CIRRUS_COMMIT_MESSAGE="" - ./script/tool_runner.sh build-examples --apk + java_test_script: + # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they + # might include non-ASCII characters which makes Gradle crash. + # TODO(stuartmorgan): See https://github.com/flutter/flutter/issues/24935 + - export CIRRUS_CHANGE_MESSAGE="" + - export CIRRUS_COMMIT_MESSAGE="" - ./script/tool_runner.sh java-test # must come after apk build + firebase_test_lab_script: + # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they + # might include non-ASCII characters which makes Gradle crash. + # TODO(stuartmorgan): See https://github.com/flutter/flutter/issues/24935 + - export CIRRUS_CHANGE_MESSAGE="" + - export CIRRUS_COMMIT_MESSAGE="" - if [[ -n "$GCLOUD_FIREBASE_TESTLAB_KEY" ]]; then - echo $GCLOUD_FIREBASE_TESTLAB_KEY > ${HOME}/gcloud-service-key.json - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 - else - echo "This user does not have permission to run Firebase Test Lab tests." - fi - - export CIRRUS_CHANGE_MESSAGE=`cat /tmp/cirrus_change_message.txt` - - export CIRRUS_COMMIT_MESSAGE=`cat /tmp/cirrus_commit_message.txt` ### Web tasks ### - name: build-web+drive-examples env: From 54082e0cb1478fcb46778ec7eb92d691b09ad603 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 28 Jun 2021 13:25:06 -0700 Subject: [PATCH 087/364] [flutter_plugin_tools] Restructure version-check (#4111) Combines the two different aspects of version-checking into a single looping structure based on plugins, using the new base command, rather than one operating on plugins as controlled by the usual flags and the other operating on a git list of changed files. Also simplifies the determination of the new version by simply checking the file, rather than querying git for the HEAD state of the file. Tests setup is simplified since we no longer need to set up nearly as much fake `git` output. Minor changes to base commands: - Move indentation up to PackageLoopingCommand so that it is consistent across commands - Add a new post-loop command for any cleanup, which is needed by version-check - Change the way the GitDir instance is managed by the base PluginCommand, so that it's always cached even when not overridden, to reduce duplicate work and code. Part of https://github.com/flutter/flutter/issues/83413 --- script/tool/lib/src/common/core.dart | 10 + .../src/common/package_looping_command.dart | 9 + .../tool/lib/src/common/plugin_command.dart | 43 ++- .../tool/lib/src/publish_plugin_command.dart | 12 +- .../tool/lib/src/pubspec_check_command.dart | 1 - .../tool/lib/src/version_check_command.dart | 344 +++++++++--------- ...t.dart => version_check_command_test.dart} | 243 ++++--------- 7 files changed, 282 insertions(+), 380 deletions(-) rename script/tool/test/{version_check_test.dart => version_check_command_test.dart} (70%) diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart index b2be8f56d172..3b07baf5dc1e 100644 --- a/script/tool/lib/src/common/core.dart +++ b/script/tool/lib/src/common/core.dart @@ -58,6 +58,16 @@ void printSuccess(String successMessage) { print(Colorize(successMessage)..green()); } +/// Prints `warningMessage` in yellow. +/// +/// Warnings are not surfaced in CI summaries, so this is only useful for +/// highlighting something when someone is already looking though the log +/// messages. DO NOT RELY on someone noticing a warning; instead, use it for +/// things that might be useful to someone debugging an unexpected result. +void printWarning(String warningMessage) { + print(Colorize(warningMessage)..yellow()); +} + /// Prints `errorMessage` in red. void printError(String errorMessage) { print(Colorize(errorMessage)..red()); diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 1349a5ed5dcc..cfe99313068e 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -35,6 +35,10 @@ abstract class PackageLoopingCommand extends PluginCommand { /// in the final summary. An empty list indicates success. Future> runForPackage(Directory package); + /// Called during [run] after all calls to [runForPackage]. This provides an + /// opportunity to do any cleanup of run-level state. + Future completeRun() async {} + /// Whether or not the output (if any) of [runForPackage] is long, or short. /// /// This changes the logging that happens at the start of each package's @@ -99,6 +103,9 @@ abstract class PackageLoopingCommand extends PluginCommand { return packageName; } + /// The suggested indentation for printed output. + String get indentation => hasLongOutput ? '' : ' '; + // ---------------------------------------- @override @@ -115,6 +122,8 @@ abstract class PackageLoopingCommand extends PluginCommand { results[package] = await runForPackage(package); } + completeRun(); + // If there were any errors reported, summarize them and exit. if (results.values.any((List failures) => failures.isNotEmpty)) { const String indentation = ' '; diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index e3ee109dd0cf..9a96ab13443d 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -20,8 +20,8 @@ abstract class PluginCommand extends Command { PluginCommand( this.packagesDir, { this.processRunner = const ProcessRunner(), - this.gitDir, - }) { + GitDir? gitDir, + }) : _gitDir = gitDir { argParser.addMultiOption( _pluginsArg, splitCommas: true, @@ -76,10 +76,11 @@ abstract class PluginCommand extends Command { /// This can be overridden for testing. final ProcessRunner processRunner; - /// The git directory to use. By default it uses the parent directory. + /// The git directory to use. If unset, [gitDir] populates it from the + /// packages directory's enclosing repository. /// /// This can be mocked for testing. - final GitDir? gitDir; + GitDir? _gitDir; int? _shardIndex; int? _shardCount; @@ -100,6 +101,26 @@ abstract class PluginCommand extends Command { return _shardCount!; } + /// Returns the [GitDir] containing [packagesDir]. + Future get gitDir async { + GitDir? gitDir = _gitDir; + if (gitDir != null) { + return gitDir; + } + + // Ensure there are no symlinks in the path, as it can break + // GitDir's allowSubdirectory:true. + final String packagesPath = packagesDir.resolveSymbolicLinksSync(); + if (!await GitDir.isGitDir(packagesPath)) { + printError('$packagesPath is not a valid Git repository.'); + throw ToolExit(2); + } + gitDir = + await GitDir.fromExisting(packagesDir.path, allowSubdirectory: true); + _gitDir = gitDir; + return gitDir; + } + /// Convenience accessor for boolean arguments. bool getBoolArg(String key) { return (argResults![key] as bool?) ?? false; @@ -291,22 +312,10 @@ abstract class PluginCommand extends Command { /// /// Throws tool exit if [gitDir] nor root directory is a git directory. Future retrieveVersionFinder() async { - final String rootDir = packagesDir.parent.absolute.path; final String baseSha = getStringArg(_kBaseSha); - GitDir? baseGitDir = gitDir; - if (baseGitDir == null) { - if (!await GitDir.isGitDir(rootDir)) { - printError( - '$rootDir is not a valid Git repository.', - ); - throw ToolExit(2); - } - baseGitDir = await GitDir.fromExisting(rootDir); - } - final GitVersionFinder gitVersionFinder = - GitVersionFinder(baseGitDir, baseSha); + GitVersionFinder(await gitDir, baseSha); return gitVersionFinder; } diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 18b6ff0ed742..740178829cac 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -149,15 +149,7 @@ class PublishPluginCommand extends PluginCommand { } _print('Checking local repo...'); - // Ensure there are no symlinks in the path, as it can break - // GitDir's allowSubdirectory:true. - final String packagesPath = packagesDir.resolveSymbolicLinksSync(); - if (!await GitDir.isGitDir(packagesPath)) { - _print('$packagesPath is not a valid Git repository.'); - throw ToolExit(1); - } - final GitDir baseGitDir = gitDir ?? - await GitDir.fromExisting(packagesPath, allowSubdirectory: true); + final GitDir repository = await gitDir; final bool shouldPushTag = getBoolArg(_pushTagsOption); _RemoteInfo? remote; @@ -179,7 +171,7 @@ class PublishPluginCommand extends PluginCommand { bool successful; if (publishAllChanged) { successful = await _publishAllChangedPackages( - baseGitDir: baseGitDir, + baseGitDir: repository, remoteForTagPush: remote, ); } else { diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 44b6b061542c..d257638971db 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -70,7 +70,6 @@ class PubspecCheckCommand extends PackageLoopingCommand { File pubspecFile, { required String packageName, }) async { - const String indentation = ' '; final String contents = pubspecFile.readAsStringSync(); final Pubspec? pubspec = _tryParsePubspec(contents); if (pubspec == null) { diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 5e9f55333f8e..2584d70c5fc9 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -6,12 +6,13 @@ import 'package:file/file.dart'; import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'common/core.dart'; import 'common/git_version_finder.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; import 'common/pub_version_finder.dart'; @@ -31,46 +32,47 @@ enum NextVersionType { } /// Returns the set of allowed next versions, with their change type, for -/// [masterVersion]. +/// [version]. /// -/// [headVerison] is used to check whether this is a pre-1.0 version bump, as +/// [newVersion] is used to check whether this is a pre-1.0 version bump, as /// those have different semver rules. @visibleForTesting Map getAllowedNextVersions( - {required Version masterVersion, required Version headVersion}) { + Version version, { + required Version newVersion, +}) { final Map allowedNextVersions = { - masterVersion.nextMajor: NextVersionType.BREAKING_MAJOR, - masterVersion.nextMinor: NextVersionType.MINOR, - masterVersion.nextPatch: NextVersionType.PATCH, + version.nextMajor: NextVersionType.BREAKING_MAJOR, + version.nextMinor: NextVersionType.MINOR, + version.nextPatch: NextVersionType.PATCH, }; - if (masterVersion.major < 1 && headVersion.major < 1) { + if (version.major < 1 && newVersion.major < 1) { int nextBuildNumber = -1; - if (masterVersion.build.isEmpty) { + if (version.build.isEmpty) { nextBuildNumber = 1; } else { - final int currentBuildNumber = masterVersion.build.first as int; + final int currentBuildNumber = version.build.first as int; nextBuildNumber = currentBuildNumber + 1; } final Version preReleaseVersion = Version( - masterVersion.major, - masterVersion.minor, - masterVersion.patch, + version.major, + version.minor, + version.patch, build: nextBuildNumber.toString(), ); allowedNextVersions.clear(); - allowedNextVersions[masterVersion.nextMajor] = NextVersionType.RELEASE; - allowedNextVersions[masterVersion.nextMinor] = - NextVersionType.BREAKING_MAJOR; - allowedNextVersions[masterVersion.nextPatch] = NextVersionType.MINOR; + allowedNextVersions[version.nextMajor] = NextVersionType.RELEASE; + allowedNextVersions[version.nextMinor] = NextVersionType.BREAKING_MAJOR; + allowedNextVersions[version.nextPatch] = NextVersionType.MINOR; allowedNextVersions[preReleaseVersion] = NextVersionType.PATCH; } return allowedNextVersions; } /// A command to validate version changes to packages. -class VersionCheckCommand extends PluginCommand { +class VersionCheckCommand extends PackageLoopingCommand { /// Creates an instance of the version check command. VersionCheckCommand( Directory packagesDir, { @@ -91,6 +93,8 @@ class VersionCheckCommand extends PluginCommand { static const String _againstPubFlag = 'against-pub'; + final PubVersionFinder _pubVersionFinder; + @override final String name = 'version-check'; @@ -100,175 +104,173 @@ class VersionCheckCommand extends PluginCommand { 'Also checks if the latest version in CHANGELOG matches the version in pubspec.\n\n' 'This command requires "pub" and "flutter" to be in your path.'; - final PubVersionFinder _pubVersionFinder; + @override + bool get hasLongOutput => false; @override - Future run() async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + Future initializeRun() async {} - final List changedPubspecs = - await gitVersionFinder.getChangedPubSpecs(); + @override + Future> runForPackage(Directory package) async { + final List errors = []; - final List badVersionChangePubspecs = []; + final Pubspec? pubspec = _tryParsePubspec(package); + if (pubspec == null) { + errors.add('Invalid pubspec.yaml.'); + return errors; // No remaining checks make sense. + } - const String indentation = ' '; - for (final String pubspecPath in changedPubspecs) { - print('Checking versions for $pubspecPath...'); - final File pubspecFile = packagesDir.fileSystem.file(pubspecPath); - if (!pubspecFile.existsSync()) { - print('${indentation}Deleted; skipping.'); - continue; - } - final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); - if (pubspec.publishTo == 'none') { - print('${indentation}Found "publish_to: none"; skipping.'); - continue; - } + if (pubspec.publishTo == 'none') { + printSkip('${indentation}Found "publish_to: none".'); + return PackageLoopingCommand.success; + } - final Version? headVersion = - await gitVersionFinder.getPackageVersion(pubspecPath, gitRef: 'HEAD'); - if (headVersion == null) { - printError('${indentation}No version found. A package that ' - 'intentionally has no version should be marked ' - '"publish_to: none".'); - badVersionChangePubspecs.add(pubspecPath); - continue; - } - Version? sourceVersion; - if (getBoolArg(_againstPubFlag)) { - final String packageName = pubspecFile.parent.basename; - final PubVersionFinderResponse pubVersionFinderResponse = - await _pubVersionFinder.getPackageVersion(package: packageName); - switch (pubVersionFinderResponse.result) { - case PubVersionFinderResult.success: - sourceVersion = pubVersionFinderResponse.versions.first; - print( - '$indentation$packageName: Current largest version on pub: $sourceVersion'); - break; - case PubVersionFinderResult.fail: - printError(''' + final Version? currentPubspecVersion = pubspec.version; + if (currentPubspecVersion == null) { + printError('${indentation}No version found in pubspec.yaml. A package ' + 'that intentionally has no version should be marked ' + '"publish_to: none".'); + errors.add('No pubspec.yaml version.'); + return errors; // No remaining checks make sense. + } + + if (!await _hasValidVersionChange(package, pubspec: pubspec)) { + errors.add('Disallowed version change.'); + } + + if (!(await _hasConsistentVersion(package, pubspec: pubspec))) { + errors.add('pubspec.yaml and CHANGELOG.md have different versions'); + } + + return errors; + } + + @override + Future completeRun() async { + _pubVersionFinder.httpClient.close(); + } + + /// Returns the previous published version of [package]. + /// + /// [packageName] must be the actual name of the package as published (i.e., + /// the name from pubspec.yaml, not the on disk name if different.) + Future _fetchPreviousVersionFromPub(String packageName) async { + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: packageName); + switch (pubVersionFinderResponse.result) { + case PubVersionFinderResult.success: + return pubVersionFinderResponse.versions.first; + case PubVersionFinderResult.fail: + printError(''' ${indentation}Error fetching version on pub for $packageName. ${indentation}HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} '''); - badVersionChangePubspecs.add(pubspecPath); - continue; - case PubVersionFinderResult.noPackageFound: - sourceVersion = null; - break; - } - } else { - sourceVersion = await gitVersionFinder.getPackageVersion(pubspecPath); - } - if (sourceVersion == null) { - String safeToIgnoreMessage; - if (getBoolArg(_againstPubFlag)) { - safeToIgnoreMessage = - '${indentation}Unable to find package on pub server.'; - } else { - safeToIgnoreMessage = - '${indentation}Unable to find pubspec in master.'; - } - print('$safeToIgnoreMessage Safe to ignore if the project is new.'); - continue; - } + return null; + case PubVersionFinderResult.noPackageFound: + return Version.none; + } + } - if (sourceVersion == headVersion) { - print('${indentation}No version change.'); - continue; - } + /// Returns the version of [package] from git at the base comparison hash. + Future _getPreviousVersionFromGit( + Directory package, { + required GitVersionFinder gitVersionFinder, + }) async { + final File pubspecFile = package.childFile('pubspec.yaml'); + return await gitVersionFinder.getPackageVersion( + p.relative(pubspecFile.absolute.path, from: (await gitDir).path)); + } - // Check for reverts when doing local validation. - if (!getBoolArg(_againstPubFlag) && headVersion < sourceVersion) { - final Map possibleVersionsFromNewVersion = - getAllowedNextVersions( - masterVersion: headVersion, headVersion: sourceVersion); - // Since this skips validation, try to ensure that it really is likely - // to be a revert rather than a typo by checking that the transition - // from the lower version to the new version would have been valid. - if (possibleVersionsFromNewVersion.containsKey(sourceVersion)) { - print('${indentation}New version is lower than previous version. ' - 'This is assumed to be a revert.'); - continue; - } + /// Returns true if the version of [package] is either unchanged relative to + /// the comparison base (git or pub, depending on flags), or is a valid + /// version transition. + Future _hasValidVersionChange( + Directory package, { + required Pubspec pubspec, + }) async { + // This method isn't called unless `version` is non-null. + final Version currentVersion = pubspec.version!; + Version? previousVersion; + if (getBoolArg(_againstPubFlag)) { + previousVersion = await _fetchPreviousVersionFromPub(pubspec.name); + if (previousVersion == null) { + return false; } - - final Map allowedNextVersions = - getAllowedNextVersions( - masterVersion: sourceVersion, headVersion: headVersion); - - if (!allowedNextVersions.containsKey(headVersion)) { - final String source = (getBoolArg(_againstPubFlag)) ? 'pub' : 'master'; - printError('${indentation}Incorrectly updated version.\n' - '${indentation}HEAD: $headVersion, $source: $sourceVersion.\n' - '${indentation}Allowed versions: $allowedNextVersions'); - badVersionChangePubspecs.add(pubspecPath); - continue; - } else { - print('$indentation$headVersion -> $sourceVersion'); + if (previousVersion != Version.none) { + print( + '$indentation${pubspec.name}: Current largest version on pub: $previousVersion'); } + } else { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + previousVersion = await _getPreviousVersionFromGit(package, + gitVersionFinder: gitVersionFinder) ?? + Version.none; + } + if (previousVersion == Version.none) { + print('${indentation}Unable to find previous version ' + '${getBoolArg(_againstPubFlag) ? 'on pub server' : 'at git base'}.'); + printWarning( + '${indentation}If this plugin is not new, something has gone wrong.'); + return true; + } - final bool isPlatformInterface = - pubspec.name.endsWith('_platform_interface'); - if (isPlatformInterface && - allowedNextVersions[headVersion] == NextVersionType.BREAKING_MAJOR) { - printError('$pubspecPath breaking change detected.\n' - 'Breaking changes to platform interfaces are strongly discouraged.\n'); - badVersionChangePubspecs.add(pubspecPath); - continue; - } + if (previousVersion == currentVersion) { + print('${indentation}No version change.'); + return true; } - _pubVersionFinder.httpClient.close(); - // TODO(stuartmorgan): Unify the way iteration works for these checks; the - // two checks shouldn't be operating independently on different lists. - final List mismatchedVersionPlugins = []; - await for (final Directory plugin in getPlugins()) { - if (!(await _checkVersionsMatch(plugin))) { - mismatchedVersionPlugins.add(plugin.basename); + // Check for reverts when doing local validation. + if (!getBoolArg(_againstPubFlag) && currentVersion < previousVersion) { + final Map possibleVersionsFromNewVersion = + getAllowedNextVersions(currentVersion, newVersion: previousVersion); + // Since this skips validation, try to ensure that it really is likely + // to be a revert rather than a typo by checking that the transition + // from the lower version to the new version would have been valid. + if (possibleVersionsFromNewVersion.containsKey(previousVersion)) { + print('${indentation}New version is lower than previous version. ' + 'This is assumed to be a revert.'); + return true; } } - bool passed = true; - if (badVersionChangePubspecs.isNotEmpty) { - passed = false; - printError(''' -The following pubspecs failed validaton: -$indentation${badVersionChangePubspecs.join('\n$indentation')} -'''); - } - if (mismatchedVersionPlugins.isNotEmpty) { - passed = false; - printError(''' -The following pubspecs have different versions in pubspec.yaml and CHANGELOG.md: -$indentation${mismatchedVersionPlugins.join('\n$indentation')} -'''); - } - if (!passed) { - throw ToolExit(1); + final Map allowedNextVersions = + getAllowedNextVersions(previousVersion, newVersion: currentVersion); + + if (allowedNextVersions.containsKey(currentVersion)) { + print('$indentation$previousVersion -> $currentVersion'); + } else { + final String source = (getBoolArg(_againstPubFlag)) ? 'pub' : 'master'; + printError('${indentation}Incorrectly updated version.\n' + '${indentation}HEAD: $currentVersion, $source: $previousVersion.\n' + '${indentation}Allowed versions: $allowedNextVersions'); + return false; } - print('No version check errors found!'); + final bool isPlatformInterface = + pubspec.name.endsWith('_platform_interface'); + // TODO(stuartmorgan): Relax this check. See + // https://github.com/flutter/flutter/issues/85391 + if (isPlatformInterface && + allowedNextVersions[currentVersion] == NextVersionType.BREAKING_MAJOR) { + printError('${indentation}Breaking change detected.\n' + '${indentation}Breaking changes to platform interfaces are strongly discouraged.\n'); + return false; + } + return true; } /// Returns whether or not the pubspec version and CHANGELOG version for /// [plugin] match. - Future _checkVersionsMatch(Directory plugin) async { - // get version from pubspec - final String packageName = plugin.basename; - print('-----------------------------------------'); - print( - 'Checking the first version listed in CHANGELOG.md matches the version in pubspec.yaml for $packageName.'); - - final Pubspec? pubspec = _tryParsePubspec(plugin); - if (pubspec == null) { - printError('Cannot parse version from pubspec.yaml'); - return false; - } - final Version? fromPubspec = pubspec.version; + Future _hasConsistentVersion( + Directory package, { + required Pubspec pubspec, + }) async { + // This method isn't called unless `version` is non-null. + final Version fromPubspec = pubspec.version!; // get first version from CHANGELOG - final File changelog = plugin.childFile('CHANGELOG.md'); + final File changelog = package.childFile('CHANGELOG.md'); final List lines = changelog.readAsLinesSync(); String? firstLineWithText; final Iterator iterator = lines.iterator; @@ -285,7 +287,8 @@ $indentation${mismatchedVersionPlugins.join('\n$indentation')} // changes that don't warrant publishing on their own. final bool hasNextSection = versionString == 'NEXT'; if (hasNextSection) { - print('Found NEXT; validating next version in the CHANGELOG.'); + print( + '${indentation}Found NEXT; validating next version in the CHANGELOG.'); // Ensure that the version in pubspec hasn't changed without updating // CHANGELOG. That means the next version entry in the CHANGELOG pass the // normal validation. @@ -301,15 +304,15 @@ $indentation${mismatchedVersionPlugins.join('\n$indentation')} versionString == null ? null : Version.parse(versionString); if (fromChangeLog == null) { printError( - 'Cannot find version on the first line of ${plugin.path}/CHANGELOG.md'); + '${indentation}Cannot find version on the first line CHANGELOG.md'); return false; } if (fromPubspec != fromChangeLog) { printError(''' -versions for $packageName in CHANGELOG.md and pubspec.yaml do not match. -The version in pubspec.yaml is $fromPubspec. -The first version listed in CHANGELOG.md is $fromChangeLog. +${indentation}Versions in CHANGELOG.md and pubspec.yaml do not match. +${indentation}The version in pubspec.yaml is $fromPubspec. +${indentation}The first version listed in CHANGELOG.md is $fromChangeLog. '''); return false; } @@ -318,15 +321,13 @@ The first version listed in CHANGELOG.md is $fromChangeLog. if (!hasNextSection) { final RegExp nextRegex = RegExp(r'^#+\s*NEXT\s*$'); if (lines.any((String line) => nextRegex.hasMatch(line))) { - printError(''' -When bumping the version for release, the NEXT section should be incorporated -into the new version's release notes. -'''); + printError('${indentation}When bumping the version for release, the ' + 'NEXT section should be incorporated into the new version\'s ' + 'release notes.'); return false; } } - print('$packageName passed version check'); return true; } @@ -337,9 +338,8 @@ into the new version's release notes. final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); return pubspec; } on Exception catch (exception) { - printError( - 'Failed to parse `pubspec.yaml` at ${pubspecFile.path}: $exception}'); + printError('${indentation}Failed to parse `pubspec.yaml`: $exception}'); + return null; } - return null; } } diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_command_test.dart similarity index 70% rename from script/tool/test/version_check_test.dart rename to script/tool/test/version_check_command_test.dart index 6035360a221c..4d884692046d 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -29,7 +29,7 @@ void testAllowedVersion( final Version master = Version.parse(masterVersion); final Version head = Version.parse(headVersion); final Map allowedVersions = - getAllowedNextVersions(masterVersion: master, headVersion: head); + getAllowedNextVersions(master, newVersion: head); if (allowed) { expect(allowedVersions, contains(head)); if (nextVersionType != null) { @@ -42,14 +42,6 @@ void testAllowedVersion( class MockProcessResult extends Mock implements io.ProcessResult {} -const String _redColorMessagePrefix = '\x1B[31m'; -const String _redColorMessagePostfix = '\x1B[0m'; - -// Some error message was printed in a "Colorized" red message. So `\x1B[31m` and `\x1B[0m` needs to be included. -String _redColorString(String string) { - return '$_redColorMessagePrefix$string$_redColorMessagePostfix'; -} - void main() { const String indentation = ' '; group('$VersionCheckCommand', () { @@ -58,7 +50,6 @@ void main() { late CommandRunner runner; late RecordingProcessRunner processRunner; late List> gitDirCommands; - String gitDiffResponse; Map gitShowResponses; late MockGitDir gitDir; @@ -66,17 +57,14 @@ void main() { fileSystem = MemoryFileSystem(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); gitDirCommands = >[]; - gitDiffResponse = ''; gitShowResponses = {}; gitDir = MockGitDir(); + when(gitDir.path).thenReturn(packagesDir.parent.path); when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) .thenAnswer((Invocation invocation) { gitDirCommands.add(invocation.positionalArguments[0] as List); final MockProcessResult mockProcessResult = MockProcessResult(); - if (invocation.positionalArguments[0][0] == 'diff') { - when(mockProcessResult.stdout as String?) - .thenReturn(gitDiffResponse); - } else if (invocation.positionalArguments[0][0] == 'show') { + if (invocation.positionalArguments[0][0] == 'show') { final String? response = gitShowResponses[invocation.positionalArguments[0][1]]; if (response == null) { @@ -100,39 +88,32 @@ void main() { }); test('allows valid version', () async { - const String newVersion = '2.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint( runner, ['version-check', '--base-sha=master']); expect( output, - containsAllInOrder([ - 'No version check errors found!', + containsAllInOrder([ + contains('Running for plugin'), + contains('1.0.0 -> 2.0.0'), ]), ); - expect(gitDirCommands.length, equals(3)); + expect(gitDirCommands.length, equals(1)); expect( gitDirCommands, containsAll([ - equals(['diff', '--name-only', 'master', 'HEAD']), equals(['show', 'master:packages/plugin/pubspec.yaml']), - equals(['show', 'HEAD:packages/plugin/pubspec.yaml']), ])); }); test('denies invalid version', () async { - const String newVersion = '0.2.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '0.2.0'); gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 0.0.1', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final Future> result = runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -141,80 +122,66 @@ void main() { result, throwsA(const TypeMatcher()), ); - expect(gitDirCommands.length, equals(3)); + expect(gitDirCommands.length, equals(1)); expect( gitDirCommands, containsAll([ - equals(['diff', '--name-only', 'master', 'HEAD']), equals(['show', 'master:packages/plugin/pubspec.yaml']), - equals(['show', 'HEAD:packages/plugin/pubspec.yaml']), ])); }); test('allows valid version without explicit base-sha', () async { - const String newVersion = '2.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check']); expect( output, - containsAllInOrder([ - 'No version check errors found!', + containsAllInOrder([ + contains('Running for plugin'), + contains('1.0.0 -> 2.0.0'), ]), ); }); test('allows valid version for new package.', () async { - const String newVersion = '1.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; - gitShowResponses = { - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', - }; + createFakePlugin('plugin', packagesDir, version: '1.0.0'); final List output = await runCapturingPrint(runner, ['version-check']); expect( output, - containsAllInOrder([ - '${indentation}Unable to find pubspec in master. Safe to ignore if the project is new.', - 'No version check errors found!', + containsAllInOrder([ + contains('Running for plugin'), + contains('Unable to find previous version at git base.'), ]), ); }); test('allows likely reverts.', () async { - const String newVersion = '0.6.1'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '0.6.1'); gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check']); expect( output, - containsAllInOrder([ - '${indentation}New version is lower than previous version. This is assumed to be a revert.', + containsAllInOrder([ + contains('New version is lower than previous version. ' + 'This is assumed to be a revert.'), ]), ); }); test('denies lower version that could not be a simple revert', () async { - const String newVersion = '0.5.1'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '0.5.1'); gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final Future> result = runCapturingPrint(runner, ['version-check']); @@ -226,12 +193,9 @@ void main() { }); test('denies invalid version without explicit base-sha', () async { - const String newVersion = '0.2.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '0.2.0'); gitShowResponses = { 'abc123:packages/plugin/pubspec.yaml': 'version: 0.0.1', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final Future> result = runCapturingPrint(runner, ['version-check']); @@ -242,73 +206,39 @@ void main() { ); }); - test('gracefully handles missing pubspec.yaml', () async { - final Directory pluginDir = - createFakePlugin('plugin', packagesDir, examples: []); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; - pluginDir.childFile('pubspec.yaml').deleteSync(); - final List output = await runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - - expect( - output, - orderedEquals([ - 'Determine diff with base sha: master', - 'Checking versions for packages/plugin/pubspec.yaml...', - ' Deleted; skipping.', - 'No version check errors found!', - ]), - ); - expect(gitDirCommands.length, equals(1)); - expect(gitDirCommands.first.join(' '), - equals('diff --name-only master HEAD')); - }); - test('allows minor changes to platform interfaces', () async { - const String newVersion = '1.1.0'; createFakePlugin('plugin_platform_interface', packagesDir, - version: newVersion); - gitDiffResponse = 'packages/plugin_platform_interface/pubspec.yaml'; + version: '1.1.0'); gitShowResponses = { 'master:packages/plugin_platform_interface/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin_platform_interface/pubspec.yaml': - 'version: $newVersion', }; final List output = await runCapturingPrint( runner, ['version-check', '--base-sha=master']); expect( output, - containsAllInOrder([ - 'No version check errors found!', + containsAllInOrder([ + contains('Running for plugin'), + contains('1.0.0 -> 1.1.0'), ]), ); - expect(gitDirCommands.length, equals(3)); + expect(gitDirCommands.length, equals(1)); expect( gitDirCommands, containsAll([ - equals(['diff', '--name-only', 'master', 'HEAD']), equals([ 'show', 'master:packages/plugin_platform_interface/pubspec.yaml' ]), - equals([ - 'show', - 'HEAD:packages/plugin_platform_interface/pubspec.yaml' - ]), ])); }); test('disallows breaking changes to platform interfaces', () async { - const String newVersion = '2.0.0'; createFakePlugin('plugin_platform_interface', packagesDir, - version: newVersion); - gitDiffResponse = 'packages/plugin_platform_interface/pubspec.yaml'; + version: '2.0.0'); gitShowResponses = { 'master:packages/plugin_platform_interface/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin_platform_interface/pubspec.yaml': - 'version: $newVersion', }; final Future> output = runCapturingPrint( runner, ['version-check', '--base-sha=master']); @@ -316,19 +246,14 @@ void main() { output, throwsA(const TypeMatcher()), ); - expect(gitDirCommands.length, equals(3)); + expect(gitDirCommands.length, equals(1)); expect( gitDirCommands, containsAll([ - equals(['diff', '--name-only', 'master', 'HEAD']), equals([ 'show', 'master:packages/plugin_platform_interface/pubspec.yaml' ]), - equals([ - 'show', - 'HEAD:packages/plugin_platform_interface/pubspec.yaml' - ]), ])); }); @@ -338,6 +263,7 @@ void main() { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, version: version); const String changelog = ''' + ## $version * Some changes. '''; @@ -346,10 +272,8 @@ void main() { runner, ['version-check', '--base-sha=master']); expect( output, - containsAllInOrder([ - 'Checking the first version listed in CHANGELOG.md matches the version in pubspec.yaml for plugin.', - 'plugin passed version check', - 'No version check errors found!' + containsAllInOrder([ + contains('Running for plugin'), ]), ); }); @@ -375,12 +299,8 @@ void main() { expect( output, - containsAllInOrder([ - _redColorString(''' -versions for plugin in CHANGELOG.md and pubspec.yaml do not match. -The version in pubspec.yaml is 1.0.1. -The first version listed in CHANGELOG.md is 1.0.2. -'''), + containsAllInOrder([ + contains('Versions in CHANGELOG.md and pubspec.yaml do not match.'), ]), ); }); @@ -399,10 +319,8 @@ The first version listed in CHANGELOG.md is 1.0.2. runner, ['version-check', '--base-sha=master']); expect( output, - containsAllInOrder([ - 'Checking the first version listed in CHANGELOG.md matches the version in pubspec.yaml for plugin.', - 'plugin passed version check', - 'No version check errors found!' + containsAllInOrder([ + contains('Running for plugin'), ]), ); }); @@ -433,14 +351,8 @@ The first version listed in CHANGELOG.md is 1.0.2. expect( output, - containsAllInOrder([ - _redColorString( - ''' -versions for plugin in CHANGELOG.md and pubspec.yaml do not match. -The version in pubspec.yaml is 1.0.0. -The first version listed in CHANGELOG.md is 1.0.1. -''', - ) + containsAllInOrder([ + contains('Versions in CHANGELOG.md and pubspec.yaml do not match.'), ]), ); }); @@ -462,10 +374,9 @@ The first version listed in CHANGELOG.md is 1.0.1. runner, ['version-check', '--base-sha=master']); await expectLater( output, - containsAllInOrder([ - 'Found NEXT; validating next version in the CHANGELOG.', - 'plugin passed version check', - 'No version check errors found!', + containsAllInOrder([ + contains('Running for plugin'), + contains('Found NEXT; validating next version in the CHANGELOG.'), ]), ); }); @@ -498,13 +409,9 @@ The first version listed in CHANGELOG.md is 1.0.1. expect( output, - containsAllInOrder([ - _redColorString( - ''' -When bumping the version for release, the NEXT section should be incorporated -into the new version's release notes. -''', - ) + containsAllInOrder([ + contains('When bumping the version for release, the NEXT section ' + 'should be incorporated into the new version\'s release notes.') ]), ); }); @@ -533,15 +440,9 @@ into the new version's release notes. expect( output, - containsAllInOrder([ - 'Found NEXT; validating next version in the CHANGELOG.', - _redColorString( - ''' -versions for plugin in CHANGELOG.md and pubspec.yaml do not match. -The version in pubspec.yaml is 1.0.1. -The first version listed in CHANGELOG.md is 1.0.0. -''', - ) + containsAllInOrder([ + contains('Found NEXT; validating next version in the CHANGELOG.'), + contains('Versions in CHANGELOG.md and pubspec.yaml do not match.'), ]), ); }); @@ -565,21 +466,17 @@ The first version listed in CHANGELOG.md is 1.0.0. 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - const String newVersion = '2.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List output = await runCapturingPrint(runner, ['version-check', '--base-sha=master', '--against-pub']); expect( output, - containsAllInOrder([ - '${indentation}plugin: Current largest version on pub: 1.0.0', - 'No version check errors found!', + containsAllInOrder([ + contains('plugin: Current largest version on pub: 1.0.0'), ]), ); }); @@ -602,12 +499,9 @@ The first version listed in CHANGELOG.md is 1.0.0. 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - const String newVersion = '2.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; bool hasError = false; @@ -623,13 +517,11 @@ The first version listed in CHANGELOG.md is 1.0.0. expect( result, - containsAllInOrder([ - _redColorString( - ''' + containsAllInOrder([ + contains(''' ${indentation}Incorrectly updated version. ${indentation}HEAD: 2.0.0, pub: 0.0.2. -${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: NextVersionType.MINOR, 0.0.3: NextVersionType.PATCH}''', - ) +${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: NextVersionType.MINOR, 0.0.3: NextVersionType.PATCH}''') ]), ); }); @@ -647,12 +539,9 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - const String newVersion = '2.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; bool hasError = false; final List result = await runCapturingPrint(runner, [ @@ -667,14 +556,12 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N expect( result, - containsAllInOrder([ - _redColorString( - ''' + containsAllInOrder([ + contains(''' ${indentation}Error fetching version on pub for plugin. ${indentation}HTTP Status 400 ${indentation}HTTP response: xx -''', - ) +''') ]), ); }); @@ -691,21 +578,17 @@ ${indentation}HTTP response: xx 'version_check_command', 'Test for $VersionCheckCommand'); runner.addCommand(command); - const String newVersion = '2.0.0'; - createFakePlugin('plugin', packagesDir, version: newVersion); - gitDiffResponse = 'packages/plugin/pubspec.yaml'; + createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', - 'HEAD:packages/plugin/pubspec.yaml': 'version: $newVersion', }; final List result = await runCapturingPrint(runner, ['version-check', '--base-sha=master', '--against-pub']); expect( result, - containsAllInOrder([ - '${indentation}Unable to find package on pub server. Safe to ignore if the project is new.', - 'No version check errors found!', + containsAllInOrder([ + contains('Unable to find previous version on pub server.'), ]), ); }); From 03f9b495010eb9b17bf6fb4dbf09abcfaa485a25 Mon Sep 17 00:00:00 2001 From: Yusuf <82844127+ydag@users.noreply.github.com> Date: Tue, 29 Jun 2021 12:01:04 +0200 Subject: [PATCH 088/364] [image_picker]Update example app (#4103) --- .../image_picker/image_picker/CHANGELOG.md | 3 + .../image_picker/example/lib/main.dart | 87 +++++++++++++++---- .../image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 428203b0327a..8385aff7e48c 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.8.1+2 + +* Update the example app to support the multi-image feature. ## 0.8.1+1 diff --git a/packages/image_picker/image_picker/example/lib/main.dart b/packages/image_picker/image_picker/example/lib/main.dart index 698de1d98898..71388ef5db2f 100755 --- a/packages/image_picker/image_picker/example/lib/main.dart +++ b/packages/image_picker/image_picker/example/lib/main.dart @@ -36,9 +36,15 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - PickedFile? _imageFile; + List? _imageFileList; + + set _imageFile(PickedFile? value) { + _imageFileList = value == null ? null : [value]; + } + dynamic _pickImageError; bool isVideo = false; + VideoPlayerController? _controller; VideoPlayerController? _toBeDisposed; String? _retrieveDataError; @@ -73,7 +79,7 @@ class _MyHomePageState extends State { } void _onImageButtonPressed(ImageSource source, - {BuildContext? context}) async { + {BuildContext? context, bool isMultiImage = false}) async { if (_controller != null) { await _controller!.setVolume(0.0); } @@ -81,6 +87,24 @@ class _MyHomePageState extends State { final PickedFile? file = await _picker.getVideo( source: source, maxDuration: const Duration(seconds: 10)); await _playVideo(file); + } else if (isMultiImage) { + await _displayPickImageDialog(context!, + (double? maxWidth, double? maxHeight, int? quality) async { + try { + final pickedFileList = await _picker.getMultiImage( + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: quality, + ); + setState(() { + _imageFileList = pickedFileList; + }); + } catch (e) { + setState(() { + _pickImageError = e; + }); + } + }); } else { await _displayPickImageDialog(context!, (double? maxWidth, double? maxHeight, int? quality) async { @@ -146,21 +170,28 @@ class _MyHomePageState extends State { ); } - Widget _previewImage() { + Widget _previewImages() { final Text? retrieveError = _getRetrieveErrorWidget(); if (retrieveError != null) { return retrieveError; } - if (_imageFile != null) { - if (kIsWeb) { - // Why network? - // See https://pub.dev/packages/image_picker#getting-ready-for-the-web-platform - return Image.network(_imageFile!.path); - } else { - return Semantics( - child: Image.file(File(_imageFile!.path)), - label: 'image_picker_example_picked_image'); - } + if (_imageFileList != null) { + return Semantics( + child: ListView.builder( + key: UniqueKey(), + itemBuilder: (context, index) { + // Why network for web? + // See https://pub.dev/packages/image_picker#getting-ready-for-the-web-platform + return Semantics( + label: 'image_picker_example_picked_image', + child: kIsWeb + ? Image.network(_imageFileList![index].path) + : Image.file(File(_imageFileList![index].path)), + ); + }, + itemCount: _imageFileList!.length, + ), + label: 'image_picker_example_picked_images'); } else if (_pickImageError != null) { return Text( 'Pick image error: $_pickImageError', @@ -174,6 +205,14 @@ class _MyHomePageState extends State { } } + Widget _handlePreview() { + if (isVideo) { + return _previewVideo(); + } else { + return _previewImages(); + } + } + Future retrieveLostData() async { final LostData response = await _picker.getLostData(); if (response.isEmpty) { @@ -213,7 +252,7 @@ class _MyHomePageState extends State { textAlign: TextAlign.center, ); case ConnectionState.done: - return isVideo ? _previewVideo() : _previewImage(); + return _handlePreview(); default: if (snapshot.hasError) { return Text( @@ -229,7 +268,7 @@ class _MyHomePageState extends State { } }, ) - : (isVideo ? _previewVideo() : _previewImage()), + : _handlePreview(), ), floatingActionButton: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -243,6 +282,22 @@ class _MyHomePageState extends State { }, heroTag: 'image0', tooltip: 'Pick Image from gallery', + child: const Icon(Icons.photo), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: FloatingActionButton( + onPressed: () { + isVideo = false; + _onImageButtonPressed( + ImageSource.gallery, + context: context, + isMultiImage: true, + ); + }, + heroTag: 'image1', + tooltip: 'Pick Multiple Image from gallery', child: const Icon(Icons.photo_library), ), ), @@ -253,7 +308,7 @@ class _MyHomePageState extends State { isVideo = false; _onImageButtonPressed(ImageSource.camera, context: context); }, - heroTag: 'image1', + heroTag: 'image2', tooltip: 'Take a Photo', child: const Icon(Icons.camera_alt), ), diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index e8fafb324e71..620d118142fb 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.1+1 +version: 0.8.1+2 environment: sdk: ">=2.12.0 <3.0.0" From 94caa8a70476b576657a0940c2b553afc2b80250 Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Tue, 29 Jun 2021 14:32:53 -0700 Subject: [PATCH 089/364] Add Basic Junit Tests to some plugins (#4108) --- .../google_sign_in/android/build.gradle | 2 + .../googlesignin/GoogleSignInTest.java | 23 +++++++++ packages/local_auth/android/build.gradle | 2 + .../plugins/localauth/LocalAuthTest.java | 22 +++++++++ .../shared_preferences/android/build.gradle | 4 ++ .../SharedPreferencesTest.java | 15 ++++++ .../video_player/android/build.gradle | 2 + .../plugins/videoplayer/VideoPlayerTest.java | 15 ++++++ packages/webview_flutter/android/build.gradle | 1 + .../webviewflutter/FlutterWebViewClient.java | 2 +- .../plugins/webviewflutter/WebViewTest.java | 49 +++++++++++++++++++ 11 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java create mode 100644 packages/local_auth/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java create mode 100644 packages/shared_preferences/shared_preferences/android/src/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java create mode 100644 packages/video_player/video_player/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java create mode 100644 packages/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java diff --git a/packages/google_sign_in/google_sign_in/android/build.gradle b/packages/google_sign_in/google_sign_in/android/build.gradle index a112470c3886..c95ba17c10d7 100644 --- a/packages/google_sign_in/google_sign_in/android/build.gradle +++ b/packages/google_sign_in/google_sign_in/android/build.gradle @@ -36,4 +36,6 @@ android { dependencies { implementation 'com.google.android.gms:play-services-auth:16.0.1' implementation 'com.google.guava:guava:20.0' + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.9.0' } diff --git a/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java new file mode 100644 index 000000000000..4e7be75aa7cf --- /dev/null +++ b/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.googlesignin; + +import static org.mockito.Mockito.mock; + +import android.content.Context; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import org.junit.Test; + +public class GoogleSignInTest { + @Test(expected = IllegalStateException.class) + public void signInThrowsWithoutActivity() { + final GoogleSignInPlugin plugin = new GoogleSignInPlugin(); + plugin.initInstance( + mock(BinaryMessenger.class), mock(Context.class), mock(GoogleSignInWrapper.class)); + + plugin.onMethodCall(new MethodCall("signIn", null), null); + } +} diff --git a/packages/local_auth/android/build.gradle b/packages/local_auth/android/build.gradle index 8878cfbcfc3e..4b0995e65946 100644 --- a/packages/local_auth/android/build.gradle +++ b/packages/local_auth/android/build.gradle @@ -37,6 +37,8 @@ dependencies { api "androidx.core:core:1.3.2" api "androidx.biometric:biometric:1.1.0" api "androidx.fragment:fragment:1.3.2" + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.9.0' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:rules:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' diff --git a/packages/local_auth/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java b/packages/local_auth/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java new file mode 100644 index 000000000000..522185fc9dd3 --- /dev/null +++ b/packages/local_auth/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java @@ -0,0 +1,22 @@ +// Copyright 2013 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. + +package io.flutter.plugins.localauth; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import org.junit.Test; + +public class LocalAuthTest { + @Test + public void isDeviceSupportedReturnsFalse() { + final LocalAuthPlugin plugin = new LocalAuthPlugin(); + final MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + plugin.onMethodCall(new MethodCall("isDeviceSupported", null), mockResult); + verify(mockResult).success(false); + } +} diff --git a/packages/shared_preferences/shared_preferences/android/build.gradle b/packages/shared_preferences/shared_preferences/android/build.gradle index 4d2336436022..9f7eeca84512 100644 --- a/packages/shared_preferences/shared_preferences/android/build.gradle +++ b/packages/shared_preferences/shared_preferences/android/build.gradle @@ -39,4 +39,8 @@ android { lintOptions { disable 'InvalidPackage' } + dependencies { + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.9.0' + } } diff --git a/packages/shared_preferences/shared_preferences/android/src/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java b/packages/shared_preferences/shared_preferences/android/src/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java new file mode 100644 index 000000000000..13d0ff8b40c1 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/android/src/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java @@ -0,0 +1,15 @@ +// Copyright 2013 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. + +package io.flutter.plugins.sharedpreferences; + +import org.junit.Test; + +public class SharedPreferencesTest { + // This is only a placeholder test and doesn't actually initialize the plugin. + @Test + public void initPluginDoesNotThrow() { + final SharedPreferencesPlugin plugin = new SharedPreferencesPlugin(); + } +} diff --git a/packages/video_player/video_player/android/build.gradle b/packages/video_player/video_player/android/build.gradle index 2f0f5c16c37a..558b4123be11 100644 --- a/packages/video_player/video_player/android/build.gradle +++ b/packages/video_player/video_player/android/build.gradle @@ -54,5 +54,7 @@ android { implementation 'com.google.android.exoplayer:exoplayer-hls:2.12.1' implementation 'com.google.android.exoplayer:exoplayer-dash:2.12.1' implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.12.1' + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.9.0' } } diff --git a/packages/video_player/video_player/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java b/packages/video_player/video_player/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java new file mode 100644 index 000000000000..ec960b7a4480 --- /dev/null +++ b/packages/video_player/video_player/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java @@ -0,0 +1,15 @@ +// Copyright 2013 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. + +package io.flutter.plugins.videoplayer; + +import org.junit.Test; + +public class VideoPlayerTest { + // This is only a placeholder test and doesn't actually initialize the plugin. + @Test + public void initPluginDoesNotThrow() { + final VideoPlayerPlugin plugin = new VideoPlayerPlugin(); + } +} diff --git a/packages/webview_flutter/android/build.gradle b/packages/webview_flutter/android/build.gradle index 0ad39b773746..45f769b4bc59 100644 --- a/packages/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/android/build.gradle @@ -36,5 +36,6 @@ android { dependencies { implementation 'androidx.annotation:annotation:1.0.0' implementation 'androidx.webkit:webkit:1.0.0' + testImplementation 'junit:junit:4.12' } } diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java index 148be952db6e..4e7056f1468c 100644 --- a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -36,7 +36,7 @@ class FlutterWebViewClient { this.methodChannel = methodChannel; } - private static String errorCodeToString(int errorCode) { + static String errorCodeToString(int errorCode) { switch (errorCode) { case WebViewClient.ERROR_AUTHENTICATION: return "authentication"; diff --git a/packages/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java b/packages/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java new file mode 100644 index 000000000000..131a5a3eb53a --- /dev/null +++ b/packages/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java @@ -0,0 +1,49 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertEquals; + +import android.webkit.WebViewClient; +import org.junit.Test; + +public class WebViewTest { + @Test + public void errorCodes() { + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_AUTHENTICATION), + "authentication"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_BAD_URL), "badUrl"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_CONNECT), "connect"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FAILED_SSL_HANDSHAKE), + "failedSslHandshake"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE), "file"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE_NOT_FOUND), "fileNotFound"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_HOST_LOOKUP), "hostLookup"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_IO), "io"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_PROXY_AUTHENTICATION), + "proxyAuthentication"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_REDIRECT_LOOP), "redirectLoop"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TIMEOUT), "timeout"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TOO_MANY_REQUESTS), + "tooManyRequests"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNKNOWN), "unknown"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSAFE_RESOURCE), + "unsafeResource"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME), + "unsupportedAuthScheme"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_SCHEME), + "unsupportedScheme"); + } +} From acc502e234b56eea01a352c3a583146f0675638c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 30 Jun 2021 14:36:04 +0200 Subject: [PATCH 090/364] [in_app_purchase] Fix app exceptions caused by missing App Store receipt (#4096) --- .../in_app_purchase_ios/CHANGELOG.md | 6 ++++- .../RunnerTests/InAppPurchasePluginTests.m | 26 +++++++++++++++--- .../example/ios/RunnerTests/Stubs.h | 3 +++ .../example/ios/RunnerTests/Stubs.m | 6 ++++- .../ios/Classes/FIAPReceiptManager.m | 27 +++++++++++++------ ...in_app_purchase_ios_platform_addition.dart | 16 ++++++----- .../in_app_purchase_ios/pubspec.yaml | 2 +- .../sk_methodchannel_apis_test.dart | 13 +++++++++ 8 files changed, 78 insertions(+), 21 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index c4c4eb05cecd..f6acc2b6d6ce 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.1+1 + +* iOS: Fix treating missing App Store receipt as an exception. + ## 0.1.1 * Added support to register a `SKPaymentQueueDelegateWrapper` and handle changes to active subscriptions accordingly (see also Store Kit's [SKPaymentQueueDelegate](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc)). @@ -13,4 +17,4 @@ ## 0.1.0 -* Initial open-source release. \ No newline at end of file +* Initial open-source release. diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m index 045abcdea922..e259e69d962c 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m @@ -11,6 +11,7 @@ @interface InAppPurchasePluginTest : XCTestCase +@property(strong, nonatomic) FIAPReceiptManagerStub* receiptManagerStub; @property(strong, nonatomic) InAppPurchasePlugin* plugin; @end @@ -18,8 +19,8 @@ @interface InAppPurchasePluginTest : XCTestCase @implementation InAppPurchasePluginTest - (void)setUp { - self.plugin = - [[InAppPurchasePluginStub alloc] initWithReceiptManager:[FIAPReceiptManagerStub new]]; + self.receiptManagerStub = [FIAPReceiptManagerStub new]; + self.plugin = [[InAppPurchasePluginStub alloc] initWithReceiptManager:self.receiptManagerStub]; } - (void)tearDown { @@ -219,7 +220,7 @@ - (void)testRestoreTransactions { XCTAssertTrue(callbackInvoked); } -- (void)testRetrieveReceiptData { +- (void)testRetrieveReceiptDataSuccess { XCTestExpectation* expectation = [self expectationWithDescription:@"receipt data retrieved"]; FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:@"-[InAppPurchasePlugin retrieveReceiptData:result:]" @@ -231,8 +232,25 @@ - (void)testRetrieveReceiptData { [expectation fulfill]; }]; [self waitForExpectations:@[ expectation ] timeout:5]; - NSLog(@"%@", result); XCTAssertNotNil(result); + XCTAssert([result isKindOfClass:[NSString class]]); +} + +- (void)testRetrieveReceiptDataError { + XCTestExpectation* expectation = [self expectationWithDescription:@"receipt data retrieved"]; + FlutterMethodCall* call = [FlutterMethodCall + methodCallWithMethodName:@"-[InAppPurchasePlugin retrieveReceiptData:result:]" + arguments:nil]; + __block NSDictionary* result; + self.receiptManagerStub.returnError = YES; + [self.plugin handleMethodCall:call + result:^(id r) { + result = r; + [expectation fulfill]; + }]; + [self waitForExpectations:@[ expectation ] timeout:5]; + XCTAssertNotNil(result); + XCTAssert([result isKindOfClass:[FlutterError class]]); } - (void)testRefreshReceiptRequest { diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h index 7b6842da4c77..085a06337386 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.h @@ -54,6 +54,9 @@ API_AVAILABLE(ios(11.2), macos(10.13.2)) @end @interface FIAPReceiptManagerStub : FIAPReceiptManager +// Indicates whether getReceiptData of this stub is going to return an error. +// Setting this to true will let getReceiptData give a basic NSError and return nil. +@property(assign, nonatomic) BOOL returnError; @end @interface SKReceiptRefreshRequestStub : SKReceiptRefreshRequest diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m index a57831c61da5..f247a7e4b78a 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m @@ -259,7 +259,11 @@ - (instancetype)initWithMap:(NSDictionary *)map { @implementation FIAPReceiptManagerStub : FIAPReceiptManager -- (NSData *)getReceiptData:(NSURL *)url { +- (NSData *)getReceiptData:(NSURL *)url error:(NSError **)error { + if (self.returnError) { + *error = [[NSError alloc] init]; + return nil; + } NSString *originalString = [NSString stringWithFormat:@"test"]; return [[NSData alloc] initWithBase64EncodedString:originalString options:kNilOptions]; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m index 526364020ad3..8038304d178f 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m @@ -5,22 +5,33 @@ #import "FIAPReceiptManager.h" #import +@interface FIAPReceiptManager () +// Gets the receipt file data from the location of the url. Can be nil if +// there is an error. This interface is defined so it can be stubbed for testing. +- (NSData *)getReceiptData:(NSURL *)url error:(NSError **)error; + +@end + @implementation FIAPReceiptManager -- (NSString *)retrieveReceiptWithError:(FlutterError **)error { +- (NSString *)retrieveReceiptWithError:(FlutterError **)flutterError { NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; - NSData *receipt = [self getReceiptData:receiptURL]; - if (!receipt) { - *error = [FlutterError errorWithCode:@"storekit_no_receipt" - message:@"Cannot find receipt for the current main bundle." - details:nil]; + NSError *receiptError; + NSData *receipt = [self getReceiptData:receiptURL error:&receiptError]; + if (!receipt || receiptError) { + if (flutterError) { + *flutterError = [FlutterError + errorWithCode:[[NSString alloc] initWithFormat:@"%li", (long)receiptError.code] + message:receiptError.domain + details:receiptError.userInfo]; + } return nil; } return [receipt base64EncodedStringWithOptions:kNilOptions]; } -- (NSData *)getReceiptData:(NSURL *)url { - return [NSData dataWithContentsOfURL:url]; +- (NSData *)getReceiptData:(NSURL *)url error:(NSError **)error { + return [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:error]; } @end diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart index bcc4ddf48200..359e51713521 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform_addition.dart @@ -21,14 +21,18 @@ class InAppPurchaseIosPlatformAddition extends InAppPurchasePlatformAddition { /// If no results, a `null` value is returned. Future refreshPurchaseVerificationData() async { await SKRequestMaker().startRefreshReceiptRequest(); - final String? receipt = await SKReceiptManager.retrieveReceiptData(); - if (receipt == null) { + try { + String receipt = await SKReceiptManager.retrieveReceiptData(); + return PurchaseVerificationData( + localVerificationData: receipt, + serverVerificationData: receipt, + source: kIAPSource); + } catch (e) { + print( + 'Something is wrong while fetching the receipt, this normally happens when the app is ' + 'running on a simulator: $e'); return null; } - return PurchaseVerificationData( - localVerificationData: receipt, - serverVerificationData: receipt, - source: kIAPSource); } /// Sets an implementation of the [SKPaymentQueueDelegateWrapper]. diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 00929d9c024b..d06e5fef0bd4 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.1 +version: 0.1.1+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index 6a01fe4caecb..7bfddaa0a32a 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -23,6 +23,7 @@ void main() { tearDown(() { fakeIOSPlatform.testReturnNull = false; fakeIOSPlatform.queueIsActive = null; + fakeIOSPlatform.getReceiptFailTest = false; }); group('sk_request_maker', () { @@ -74,6 +75,12 @@ void main() { expect(fakeIOSPlatform.refreshReceiptParam, {"isExpired": true}); }); + + test('should get null receipt if any exceptions are raised', () async { + fakeIOSPlatform.getReceiptFailTest = true; + expect(() async => SKReceiptManager.retrieveReceiptData(), + throwsA(TypeMatcher())); + }); }); group('sk_receipt_manager', () { @@ -180,6 +187,9 @@ class FakeIOSPlatform { bool getProductRequestFailTest = false; bool testReturnNull = false; + // get receipt request + bool getReceiptFailTest = false; + // refresh receipt request int refreshReceipt = 0; late Map refreshReceiptParam; @@ -221,6 +231,9 @@ class FakeIOSPlatform { return Future.sync(() {}); // receipt manager case '-[InAppPurchasePlugin retrieveReceiptData:result:]': + if (getReceiptFailTest) { + throw ("some arbitrary error"); + } return Future.value('receipt data'); // payment queue case '-[SKPaymentQueue canMakePayments:]': From 9ab0be9db386af84fee8918bd6ea5d2da4a220ba Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Wed, 30 Jun 2021 16:41:04 +0200 Subject: [PATCH 091/364] [image_picker] Fixed IOException when cache directory is removed (#4117) --- .../image_picker/image_picker/CHANGELOG.md | 4 ++++ .../imagepicker/ImagePickerDelegate.java | 1 + .../imagepicker/ImagePickerDelegateTest.java | 19 +++++++++++++------ .../image_picker/image_picker/pubspec.yaml | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 8385aff7e48c..0e49912b4ed4 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1+3 + +* Fix image picker causing a crash when the cache directory is deleted. + ## 0.8.1+2 * Update the example app to support the multi-image feature. diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index c4a686f5ce13..8b904f5d769d 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -401,6 +401,7 @@ private File createTemporaryWritableFile(String suffix) { File image; try { + externalFilesDirectory.mkdirs(); image = File.createTempFile(filename, suffix, externalFilesDirectory); } catch (IOException e) { throw new RuntimeException(e); diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index f8be66833b17..1b55a7569eac 100644 --- a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -23,6 +23,7 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import java.io.File; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -47,6 +48,7 @@ public class ImagePickerDelegateTest { @Mock ImagePickerCache cache; ImagePickerDelegate.FileUriResolver mockFileUriResolver; + MockedStatic mockStaticFile; private static class MockFileUriResolver implements ImagePickerDelegate.FileUriResolver { @Override @@ -64,6 +66,11 @@ public void getFullImagePath(Uri imageUri, ImagePickerDelegate.OnPathReadyListen public void setUp() { MockitoAnnotations.initMocks(this); + mockStaticFile = Mockito.mockStatic(File.class); + mockStaticFile + .when(() -> File.createTempFile(any(), any(), any())) + .thenReturn(new File("/tmpfile")); + when(mockActivity.getPackageName()).thenReturn("com.example.test"); when(mockActivity.getPackageManager()).thenReturn(mock(PackageManager.class)); @@ -87,6 +94,11 @@ public void setUp() { when(mockIntent.getData()).thenReturn(mockUri); } + @After + public void tearDown() { + mockStaticFile.close(); + } + @Test public void whenConstructed_setsCorrectFileProviderName() { ImagePickerDelegate delegate = createDelegate(); @@ -195,11 +207,6 @@ public void takeImageWithCamera_WritesImageToCacheDirectory() { when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); - MockedStatic mockStaticFile = Mockito.mockStatic(File.class); - mockStaticFile - .when(() -> File.createTempFile(any(), any(), any())) - .thenReturn(new File("/tmpfile")); - ImagePickerDelegate delegate = createDelegate(); delegate.takeImageWithCamera(mockMethodCall, mockResult); @@ -380,7 +387,7 @@ private ImagePickerDelegate createDelegate() { private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { return new ImagePickerDelegate( mockActivity, - null, + new File("/image_picker_cache"), mockImageResizer, mockResult, mockMethodCall, diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 620d118142fb..bcda757b4bbf 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.1+2 +version: 0.8.1+3 environment: sdk: ">=2.12.0 <3.0.0" From 7e2560a00b910a88a3768d1b92c19a2a1c260e36 Mon Sep 17 00:00:00 2001 From: Csaba Toth Date: Wed, 30 Jun 2021 10:49:19 -0700 Subject: [PATCH 092/364] [url_launcher] Amend example's manifest and docs (#4006) --- .../url_launcher/url_launcher/CHANGELOG.md | 3 +- packages/url_launcher/url_launcher/README.md | 31 +++++++++++++++++++ .../example/android/app/build.gradle | 4 +-- .../android/app/src/main/AndroidManifest.xml | 23 +++++++++++--- .../url_launcher/example/android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../url_launcher/lib/url_launcher.dart | 6 ++-- .../url_launcher/url_launcher/pubspec.yaml | 2 +- 8 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index bbc5f2445a9e..1e7104c453e2 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 6.0.8 +* Adding API level 30 required package visibility configuration to the example's AndroidManifest.xml and README * Fix test button check for iOS 15. ## 6.0.7 diff --git a/packages/url_launcher/url_launcher/README.md b/packages/url_launcher/url_launcher/README.md index 20ee0a59caa8..c649b5c0fe7b 100644 --- a/packages/url_launcher/url_launcher/README.md +++ b/packages/url_launcher/url_launcher/README.md @@ -49,6 +49,37 @@ void _launchURL() async => await canLaunch(_url) ? await launch(_url) : throw 'Could not launch $_url'; ``` +### Android + +Starting from API 30 Android requires package visibility configuration in your +`AndroidManifest.xml` otherwise `canLaunch` will return `false`. A `` +element must be added to your manifest as a child of the root element. + +The snippet below shows an example for an application that uses `https`, `tel`, +and `mailto` URLs with `url_launcher`. See +[the Android documentation](https://developer.android.com/training/package-visibility/use-cases) +for examples of other queries. + +``` xml + + + + + + + + + + + + + + + + + +``` + ## Supported URL schemes The [`launch`](https://pub.dev/documentation/url_launcher/latest/url_launcher/launch.html) method diff --git a/packages/url_launcher/url_launcher/example/android/app/build.gradle b/packages/url_launcher/url_launcher/example/android/app/build.gradle index 4620c8963b47..8280da86f124 100644 --- a/packages/url_launcher/url_launcher/example/android/app/build.gradle +++ b/packages/url_launcher/url_launcher/example/android/app/build.gradle @@ -25,7 +25,7 @@ apply plugin: 'com.android.application' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 29 + compileSdkVersion 30 lintOptions { disable 'InvalidPackage' @@ -34,7 +34,7 @@ android { defaultConfig { applicationId "io.flutter.plugins.urllauncherexample" minSdkVersion 16 - targetSdkVersion 28 + targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml b/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml index 37035799fea8..d6753c9bbdbc 100644 --- a/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml +++ b/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,24 @@ + + + + + + + + + + + + + + + - diff --git a/packages/url_launcher/url_launcher/example/android/build.gradle b/packages/url_launcher/url_launcher/example/android/build.gradle index 4d553dd548c7..328175bb6ac5 100644 --- a/packages/url_launcher/url_launcher/example/android/build.gradle +++ b/packages/url_launcher/url_launcher/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.4.2' + classpath 'com.android.tools.build:gradle:4.2.1' } } diff --git a/packages/url_launcher/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/url_launcher/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties index 1cedb28ea41f..4ae10e927b38 100644 --- a/packages/url_launcher/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/url_launcher/url_launcher/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index b59c91d02a1a..e8d9670ec6d4 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -117,8 +117,10 @@ Future launch( /// /// On Android (from API 30), [canLaunch] will return `false` when the required /// visibility configuration is not provided in the AndroidManifest.xml file. -/// For more information see the [Managing package visibility](https://developer.android.com/training/basics/intents/package-visibility) -/// article in the Android docs. +/// For more information see the +/// [Package visibility filtering on Android](https://developer.android.com/training/basics/intents/package-visibility) +/// article in the Android documentation or the url_launcher example app's +/// [AndroidManifest.xml's queries element](https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml). Future canLaunch(String urlString) async { return await UrlLauncherPlatform.instance.canLaunch(urlString); } diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index a2facbd3adf2..28dc71c56346 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.7 +version: 6.0.8 environment: sdk: ">=2.12.0 <3.0.0" From 6e1d7241a81a357ab6ad949c14ae120d91985f7b Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 30 Jun 2021 11:21:05 -0700 Subject: [PATCH 093/364] [flutter_plugin_tools] Move license-check tests to runCapturingPrint (#4107) --- .../tool/lib/src/license_check_command.dart | 40 ++--- .../tool/test/license_check_command_test.dart | 170 ++++++++++-------- 2 files changed, 114 insertions(+), 96 deletions(-) diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index 4ea8a1e09392..1d3e49c6a7c6 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -96,13 +96,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. /// Validates that code files have copyright and license blocks. class LicenseCheckCommand extends PluginCommand { /// Creates a new license check command for [packagesDir]. - LicenseCheckCommand( - Directory packagesDir, { - Print print = print, - }) : _print = print, - super(packagesDir); - - final Print _print; + LicenseCheckCommand(Directory packagesDir) : super(packagesDir); @override final String name = 'license-check'; @@ -121,7 +115,7 @@ class LicenseCheckCommand extends PluginCommand { p.basename(file.basename) == 'LICENSE' && !_isThirdParty(file)); final bool copyrightCheckSucceeded = await _checkCodeLicenses(codeFiles); - _print('\n=======================================\n'); + print('\n=======================================\n'); final bool licenseCheckSucceeded = await _checkLicenseFiles(firstPartyLicenseFiles); @@ -157,7 +151,7 @@ class LicenseCheckCommand extends PluginCommand { }; for (final File file in codeFiles) { - _print('Checking ${file.path}'); + print('Checking ${file.path}'); final String content = await file.readAsString(); final String firstParyLicense = @@ -177,7 +171,7 @@ class LicenseCheckCommand extends PluginCommand { } } } - _print('\n'); + print('\n'); // Sort by path for more usable output. final int Function(File, File) pathCompare = @@ -186,22 +180,22 @@ class LicenseCheckCommand extends PluginCommand { unrecognizedThirdPartyFiles.sort(pathCompare); if (incorrectFirstPartyFiles.isNotEmpty) { - _print('The license block for these files is missing or incorrect:'); + print('The license block for these files is missing or incorrect:'); for (final File file in incorrectFirstPartyFiles) { - _print(' ${file.path}'); + print(' ${file.path}'); } - _print('If this third-party code, move it to a "third_party/" directory, ' + print('If this third-party code, move it to a "third_party/" directory, ' 'otherwise ensure that you are using the exact copyright and license ' 'text used by all first-party files in this repository.\n'); } if (unrecognizedThirdPartyFiles.isNotEmpty) { - _print( + print( 'No recognized license was found for the following third-party files:'); for (final File file in unrecognizedThirdPartyFiles) { - _print(' ${file.path}'); + print(' ${file.path}'); } - _print('Please check that they have a license at the top of the file. ' + print('Please check that they have a license at the top of the file. ' 'If they do, the license check needs to be updated to recognize ' 'the new third-party license block.\n'); } @@ -209,7 +203,7 @@ class LicenseCheckCommand extends PluginCommand { final bool succeeded = incorrectFirstPartyFiles.isEmpty && unrecognizedThirdPartyFiles.isEmpty; if (succeeded) { - _print('All source files passed validation!'); + print('All source files passed validation!'); } return succeeded; } @@ -220,25 +214,25 @@ class LicenseCheckCommand extends PluginCommand { final List incorrectLicenseFiles = []; for (final File file in files) { - _print('Checking ${file.path}'); + print('Checking ${file.path}'); if (!file.readAsStringSync().contains(_fullBsdLicenseText)) { incorrectLicenseFiles.add(file); } } - _print('\n'); + print('\n'); if (incorrectLicenseFiles.isNotEmpty) { - _print('The following LICENSE files do not follow the expected format:'); + print('The following LICENSE files do not follow the expected format:'); for (final File file in incorrectLicenseFiles) { - _print(' ${file.path}'); + print(' ${file.path}'); } - _print( + print( 'Please ensure that they use the exact format used in this repository".\n'); } final bool succeeded = incorrectLicenseFiles.isEmpty; if (succeeded) { - _print('All LICENSE files passed validation!'); + print('All LICENSE files passed validation!'); } return succeeded; } diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart index dfe8d25197ab..64adc9214d80 100644 --- a/script/tool/test/license_check_command_test.dart +++ b/script/tool/test/license_check_command_test.dart @@ -9,11 +9,12 @@ import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/license_check_command.dart'; import 'package:test/test.dart'; +import 'util.dart'; + void main() { group('$LicenseCheckCommand', () { late CommandRunner runner; late FileSystem fileSystem; - late List printedMessages; late Directory root; setUp(() { @@ -22,10 +23,8 @@ void main() { fileSystem.currentDirectory.childDirectory('packages'); root = packagesDir.parent; - printedMessages = []; final LicenseCheckCommand command = LicenseCheckCommand( packagesDir, - print: (Object? message) => printedMessages.add(message.toString()), ); runner = CommandRunner('license_test', 'Test for $LicenseCheckCommand'); @@ -81,18 +80,16 @@ void main() { root.childFile('$filenameBase.$fileExtension').createSync(); } - try { - await runner.run(['license-check']); - } on ToolExit { + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { // Ignore failure; the files are empty so the check is expected to fail, // but this test isn't for that behavior. - } + }); extensions.forEach((String fileExtension, bool shouldCheck) { final Matcher logLineMatcher = contains('Checking $filenameBase.$fileExtension'); - expect(printedMessages, - shouldCheck ? logLineMatcher : isNot(logLineMatcher)); + expect(output, shouldCheck ? logLineMatcher : isNot(logLineMatcher)); }); }); @@ -115,10 +112,11 @@ void main() { root.childFile(name).createSync(); } - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); for (final String name in ignoredFiles) { - expect(printedMessages, isNot(contains('Checking $name'))); + expect(output, isNot(contains('Checking $name'))); } }); @@ -129,11 +127,12 @@ void main() { final File notChecked = root.childFile('not_checked.md'); notChecked.createSync(); - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check a file. - expect(printedMessages, contains('Checking checked.cc')); - expect(printedMessages, contains('All source files passed validation!')); + expect(output, contains('Checking checked.cc')); + expect(output, contains('All source files passed validation!')); }); test('handles the comment styles for all supported languages', () async { @@ -147,13 +146,14 @@ void main() { fileC.createSync(); _writeLicense(fileC, comment: '', prefix: ''); - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the files. - expect(printedMessages, contains('Checking file_a.cc')); - expect(printedMessages, contains('Checking file_b.sh')); - expect(printedMessages, contains('Checking file_c.html')); - expect(printedMessages, contains('All source files passed validation!')); + expect(output, contains('Checking file_a.cc')); + expect(output, contains('Checking file_b.sh')); + expect(output, contains('Checking file_c.html')); + expect(output, contains('All source files passed validation!')); }); test('fails if any checked files are missing license blocks', () async { @@ -166,19 +166,22 @@ void main() { root.childFile('bad.cc').createSync(); root.childFile('bad.h').createSync(); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); // Failure should give information about the problematic files. expect( - printedMessages, + output, contains( 'The license block for these files is missing or incorrect:')); - expect(printedMessages, contains(' bad.cc')); - expect(printedMessages, contains(' bad.h')); + expect(output, contains(' bad.cc')); + expect(output, contains(' bad.h')); // Failure shouldn't print the success message. - expect(printedMessages, - isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains('All source files passed validation!'))); }); test('fails if any checked files are missing just the copyright', () async { @@ -189,18 +192,21 @@ void main() { bad.createSync(); _writeLicense(bad, copyright: ''); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); // Failure should give information about the problematic files. expect( - printedMessages, + output, contains( 'The license block for these files is missing or incorrect:')); - expect(printedMessages, contains(' bad.cc')); + expect(output, contains(' bad.cc')); // Failure shouldn't print the success message. - expect(printedMessages, - isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains('All source files passed validation!'))); }); test('fails if any checked files are missing just the license', () async { @@ -211,18 +217,21 @@ void main() { bad.createSync(); _writeLicense(bad, license: []); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); // Failure should give information about the problematic files. expect( - printedMessages, + output, contains( 'The license block for these files is missing or incorrect:')); - expect(printedMessages, contains(' bad.cc')); + expect(output, contains(' bad.cc')); // Failure shouldn't print the success message. - expect(printedMessages, - isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains('All source files passed validation!'))); }); test('fails if any third-party code is not in a third_party directory', @@ -231,18 +240,21 @@ void main() { thirdPartyFile.createSync(); _writeLicense(thirdPartyFile, copyright: 'Copyright 2017 Someone Else'); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); // Failure should give information about the problematic files. expect( - printedMessages, + output, contains( 'The license block for these files is missing or incorrect:')); - expect(printedMessages, contains(' third_party.cc')); + expect(output, contains(' third_party.cc')); // Failure shouldn't print the success message. - expect(printedMessages, - isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains('All source files passed validation!'))); }); test('succeeds for third-party code in a third_party directory', () async { @@ -260,12 +272,12 @@ void main() { 'you may not use this file except in compliance with the License.' ]); - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the file. - expect(printedMessages, - contains('Checking a_plugin/lib/src/third_party/file.cc')); - expect(printedMessages, contains('All source files passed validation!')); + expect(output, contains('Checking a_plugin/lib/src/third_party/file.cc')); + expect(output, contains('All source files passed validation!')); }); test('allows first-party code in a third_party directory', () async { @@ -278,12 +290,13 @@ void main() { firstPartyFileInThirdParty.createSync(recursive: true); _writeLicense(firstPartyFileInThirdParty); - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the file. - expect(printedMessages, + expect(output, contains('Checking a_plugin/lib/src/third_party/first_party.cc')); - expect(printedMessages, contains('All source files passed validation!')); + expect(output, contains('All source files passed validation!')); }); test('fails for licenses that the tool does not expect', () async { @@ -297,18 +310,21 @@ void main() { 'it under the terms of the GNU General Public License', ]); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); // Failure should give information about the problematic files. expect( - printedMessages, + output, contains( 'No recognized license was found for the following third-party files:')); - expect(printedMessages, contains(' third_party/bad.cc')); + expect(output, contains(' third_party/bad.cc')); // Failure shouldn't print the success message. - expect(printedMessages, - isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains('All source files passed validation!'))); }); test('Apache is not recognized for new authors without validation changes', @@ -327,18 +343,21 @@ void main() { ], ); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); // Failure should give information about the problematic files. expect( - printedMessages, + output, contains( 'No recognized license was found for the following third-party files:')); - expect(printedMessages, contains(' third_party/bad.cc')); + expect(output, contains(' third_party/bad.cc')); // Failure shouldn't print the success message. - expect(printedMessages, - isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains('All source files passed validation!'))); }); test('passes if all first-party LICENSE files are correctly formatted', @@ -347,11 +366,12 @@ void main() { license.createSync(); license.writeAsStringSync(_correctLicenseFileText); - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the file. - expect(printedMessages, contains('Checking LICENSE')); - expect(printedMessages, contains('All LICENSE files passed validation!')); + expect(output, contains('Checking LICENSE')); + expect(output, contains('All LICENSE files passed validation!')); }); test('fails if any first-party LICENSE files are incorrectly formatted', @@ -360,11 +380,14 @@ void main() { license.createSync(); license.writeAsStringSync(_incorrectLicenseFileText); - await expectLater(() => runner.run(['license-check']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); - expect(printedMessages, - isNot(contains('All LICENSE files passed validation!'))); + expect(commandError, isA()); + expect(output, isNot(contains('All LICENSE files passed validation!'))); }); test('ignores third-party LICENSE format', () async { @@ -373,11 +396,12 @@ void main() { license.createSync(recursive: true); license.writeAsStringSync(_incorrectLicenseFileText); - await runner.run(['license-check']); + final List output = + await runCapturingPrint(runner, ['license-check']); // The file shouldn't be checked. - expect(printedMessages, isNot(contains('Checking third_party/LICENSE'))); - expect(printedMessages, contains('All LICENSE files passed validation!')); + expect(output, isNot(contains('Checking third_party/LICENSE'))); + expect(output, contains('All LICENSE files passed validation!')); }); }); } From 01395862f4e015f92f4686030136b105a32d13a5 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 30 Jun 2021 11:33:09 -0700 Subject: [PATCH 094/364] [flutter_plugin_tools] Migrate build-examples to new base command (#4087) Switches build-examples to the new base command that handles the boilerplate of looping over target packages. While modifying the command, also does some minor cleanup: - Extracts a helper to reduce duplicated details of calling `flutter build` - Switches the flag for iOS to `--ios` rather than `--ipa` since `ios` is what is actually passed to the build command - iOS no longer defaults to on, so that it behaves like all the other platform flags - Passing no platform flags is now an error rather than a silent pass, to ensure that we never accidentally have CI doing a no-op run without noticing. - Rewords the logging slightly for the versions where the label for what is being built is a platform, not an artifact (which is now everything but Android). Part of flutter/flutter#83413 --- .cirrus.yml | 4 +- script/tool/CHANGELOG.md | 2 + .../tool/lib/src/build_examples_command.dart | 241 ++++++++---------- .../test/build_examples_command_test.dart | 146 +++++------ 4 files changed, 161 insertions(+), 232 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index 45f944283134..4ad5e8e03a25 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -220,7 +220,7 @@ task: - xcrun simctl list - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-5 | xargs xcrun simctl boot build_script: - - ./script/tool_runner.sh build-examples --ipa + - ./script/tool_runner.sh build-examples --ios xctest_script: - ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" drive_script: @@ -248,7 +248,7 @@ task: PATH: $PATH:/usr/local/bin build_script: - flutter config --enable-macos-desktop - - ./script/tool_runner.sh build-examples --macos --no-ipa + - ./script/tool_runner.sh build-examples --macos xctest_script: - ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS drive_script: diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 94514e38103a..a2716cb53d34 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -5,6 +5,8 @@ compatibility. - `xctest` now supports running macOS tests in addition to iOS - **Breaking change**: it now requires an `--ios` and/or `--macos` flag. +- **Breaking change**: `build-examples` for iOS now uses `--ios` rather than + `--ipa`. - The tooling now runs in strong null-safe mode. - `publish plugins` check against pub.dev to determine if a release should happen. - Modified the output format of many commands diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index aff5ecba4989..c8280f4e867c 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -3,36 +3,34 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io' as io; import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; -/// Key for IPA. -const String kIpa = 'ipa'; - /// Key for APK. -const String kApk = 'apk'; +const String _platformFlagApk = 'apk'; + +const int _exitNoPlatformFlags = 2; /// A command to build the example applications for packages. -class BuildExamplesCommand extends PluginCommand { +class BuildExamplesCommand extends PackageLoopingCommand { /// Creates an instance of the build command. BuildExamplesCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), }) : super(packagesDir, processRunner: processRunner) { - argParser.addFlag(kPlatformLinux, defaultsTo: false); - argParser.addFlag(kPlatformMacos, defaultsTo: false); - argParser.addFlag(kPlatformWeb, defaultsTo: false); - argParser.addFlag(kPlatformWindows, defaultsTo: false); - argParser.addFlag(kIpa, defaultsTo: io.Platform.isMacOS); - argParser.addFlag(kApk); + argParser.addFlag(kPlatformLinux); + argParser.addFlag(kPlatformMacos); + argParser.addFlag(kPlatformWeb); + argParser.addFlag(kPlatformWindows); + argParser.addFlag(kPlatformIos); + argParser.addFlag(_platformFlagApk); argParser.addOption( kEnableExperiment, defaultsTo: '', @@ -49,164 +47,125 @@ class BuildExamplesCommand extends PluginCommand { 'This command requires "flutter" to be in your path.'; @override - Future run() async { + Future initializeRun() async { final List platformSwitches = [ - kApk, - kIpa, + _platformFlagApk, + kPlatformIos, kPlatformLinux, kPlatformMacos, kPlatformWeb, kPlatformWindows, ]; if (!platformSwitches.any((String platform) => getBoolArg(platform))) { - print( + printError( 'None of ${platformSwitches.map((String platform) => '--$platform').join(', ')} ' - 'were specified, so not building anything.'); - return; + 'were specified. At least one platform must be provided.'); + throw ToolExit(_exitNoPlatformFlags); } - final String flutterCommand = - const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; - - final String enableExperiment = getStringArg(kEnableExperiment); + } - final List failingPackages = []; - await for (final Directory plugin in getPlugins()) { - for (final Directory example in getExamplesForPlugin(plugin)) { - final String packageName = - p.relative(example.path, from: packagesDir.path); - - if (getBoolArg(kPlatformLinux)) { - print('\nBUILDING Linux for $packageName'); - if (isLinuxPlugin(plugin)) { - final int buildExitCode = await processRunner.runAndStream( - flutterCommand, - [ - 'build', - kPlatformLinux, - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: example); - if (buildExitCode != 0) { - failingPackages.add('$packageName (linux)'); - } - } else { - print('Linux is not supported by this plugin'); + @override + Future> runForPackage(Directory package) async { + final List errors = []; + + for (final Directory example in getExamplesForPlugin(package)) { + final String packageName = + p.relative(example.path, from: packagesDir.path); + + if (getBoolArg(kPlatformLinux)) { + print('\nBUILDING $packageName for Linux'); + if (isLinuxPlugin(package)) { + if (!await _buildExample(example, kPlatformLinux)) { + errors.add('$packageName (Linux)'); } + } else { + printSkip('Linux is not supported by this plugin'); } + } - if (getBoolArg(kPlatformMacos)) { - print('\nBUILDING macOS for $packageName'); - if (isMacOsPlugin(plugin)) { - final int exitCode = await processRunner.runAndStream( - flutterCommand, - [ - 'build', - kPlatformMacos, - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: example); - if (exitCode != 0) { - failingPackages.add('$packageName (macos)'); - } - } else { - print('macOS is not supported by this plugin'); + if (getBoolArg(kPlatformMacos)) { + print('\nBUILDING $packageName for macOS'); + if (isMacOsPlugin(package)) { + if (!await _buildExample(example, kPlatformMacos)) { + errors.add('$packageName (macOS)'); } + } else { + printSkip('macOS is not supported by this plugin'); } + } - if (getBoolArg(kPlatformWeb)) { - print('\nBUILDING web for $packageName'); - if (isWebPlugin(plugin)) { - final int buildExitCode = await processRunner.runAndStream( - flutterCommand, - [ - 'build', - kPlatformWeb, - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: example); - if (buildExitCode != 0) { - failingPackages.add('$packageName (web)'); - } - } else { - print('Web is not supported by this plugin'); + if (getBoolArg(kPlatformWeb)) { + print('\nBUILDING $packageName for web'); + if (isWebPlugin(package)) { + if (!await _buildExample(example, kPlatformWeb)) { + errors.add('$packageName (web)'); } + } else { + printSkip('Web is not supported by this plugin'); } + } - if (getBoolArg(kPlatformWindows)) { - print('\nBUILDING Windows for $packageName'); - if (isWindowsPlugin(plugin)) { - final int buildExitCode = await processRunner.runAndStream( - flutterCommand, - [ - 'build', - kPlatformWindows, - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: example); - if (buildExitCode != 0) { - failingPackages.add('$packageName (windows)'); - } - } else { - print('Windows is not supported by this plugin'); + if (getBoolArg(kPlatformWindows)) { + print('\nBUILDING $packageName for Windows'); + if (isWindowsPlugin(package)) { + if (!await _buildExample(example, kPlatformWindows)) { + errors.add('$packageName (Windows)'); } + } else { + printSkip('Windows is not supported by this plugin'); } + } - if (getBoolArg(kIpa)) { - print('\nBUILDING IPA for $packageName'); - if (isIosPlugin(plugin)) { - final int exitCode = await processRunner.runAndStream( - flutterCommand, - [ - 'build', - 'ios', - '--no-codesign', - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: example); - if (exitCode != 0) { - failingPackages.add('$packageName (ipa)'); - } - } else { - print('iOS is not supported by this plugin'); + if (getBoolArg(kPlatformIos)) { + print('\nBUILDING $packageName for iOS'); + if (isIosPlugin(package)) { + if (!await _buildExample( + example, + kPlatformIos, + extraBuildFlags: ['--no-codesign'], + )) { + errors.add('$packageName (iOS)'); } + } else { + printSkip('iOS is not supported by this plugin'); } + } - if (getBoolArg(kApk)) { - print('\nBUILDING APK for $packageName'); - if (isAndroidPlugin(plugin)) { - final int exitCode = await processRunner.runAndStream( - flutterCommand, - [ - 'build', - 'apk', - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: example); - if (exitCode != 0) { - failingPackages.add('$packageName (apk)'); - } - } else { - print('Android is not supported by this plugin'); + if (getBoolArg(_platformFlagApk)) { + print('\nBUILDING APK for $packageName'); + if (isAndroidPlugin(package)) { + if (!await _buildExample(example, _platformFlagApk)) { + errors.add('$packageName (apk)'); } + } else { + printSkip('Android is not supported by this plugin'); } } } - print('\n\n'); - if (failingPackages.isNotEmpty) { - print('The following build are failing (see above for details):'); - for (final String package in failingPackages) { - print(' * $package'); - } - throw ToolExit(1); - } + return errors; + } - print('All builds successful!'); + Future _buildExample( + Directory example, + String flutterBuildType, { + List extraBuildFlags = const [], + }) async { + final String flutterCommand = + const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; + final String enableExperiment = getStringArg(kEnableExperiment); + + final int exitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'build', + flutterBuildType, + ...extraBuildFlags, + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: example, + ); + return exitCode == 0; } } diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 7fc97838c0ee..c6febdc26fb8 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -15,7 +15,7 @@ import 'package:test/test.dart'; import 'util.dart'; void main() { - group('test build_example_command', () { + group('build-example', () { late FileSystem fileSystem; late Directory packagesDir; late CommandRunner runner; @@ -35,6 +35,13 @@ void main() { runner.addCommand(command); }); + test('fails if no plaform flags are passed', () async { + expect( + () => runCapturingPrint(runner, ['build-examples']), + throwsA(isA()), + ); + }); + test('building for iOS when plugin is not set up for iOS results in no-op', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, @@ -43,18 +50,16 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( - runner, ['build-examples', '--ipa', '--no-macos']); + final List output = + await runCapturingPrint(runner, ['build-examples', '--ios']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING IPA for $packageName', - 'iOS is not supported by this plugin', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + contains('BUILDING $packageName for iOS'), + contains('iOS is not supported by this plugin'), ]), ); @@ -78,21 +83,15 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint(runner, [ - 'build-examples', - '--ipa', - '--no-macos', - '--enable-experiment=exp1' - ]); + final List output = await runCapturingPrint(runner, + ['build-examples', '--ios', '--enable-experiment=exp1']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING IPA for $packageName', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + '\nBUILDING $packageName for iOS', ]), ); @@ -123,17 +122,15 @@ void main() { pluginDirectory.childDirectory('example'); final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--linux']); + runner, ['build-examples', '--linux']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING Linux for $packageName', - 'Linux is not supported by this plugin', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + contains('BUILDING $packageName for Linux'), + contains('Linux is not supported by this plugin'), ]), ); @@ -158,16 +155,14 @@ void main() { pluginDirectory.childDirectory('example'); final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--linux']); + runner, ['build-examples', '--linux']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING Linux for $packageName', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + '\nBUILDING $packageName for Linux', ]), ); @@ -190,17 +185,15 @@ void main() { pluginDirectory.childDirectory('example'); final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--macos']); + runner, ['build-examples', '--macos']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING macOS for $packageName', - 'macOS is not supported by this plugin', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + contains('BUILDING $packageName for macOS'), + contains('macOS is not supported by this plugin'), ]), ); @@ -226,16 +219,14 @@ void main() { pluginDirectory.childDirectory('example'); final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--macos']); + runner, ['build-examples', '--macos']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING macOS for $packageName', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + '\nBUILDING $packageName for macOS', ]), ); @@ -256,18 +247,16 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--web']); + final List output = + await runCapturingPrint(runner, ['build-examples', '--web']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING web for $packageName', - 'Web is not supported by this plugin', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + contains('BUILDING $packageName for web'), + contains('Web is not supported by this plugin'), ]), ); @@ -292,17 +281,15 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--web']); + final List output = + await runCapturingPrint(runner, ['build-examples', '--web']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING web for $packageName', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + '\nBUILDING $packageName for web', ]), ); @@ -326,17 +313,15 @@ void main() { pluginDirectory.childDirectory('example'); final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--windows']); + runner, ['build-examples', '--windows']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING Windows for $packageName', - 'Windows is not supported by this plugin', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + contains('BUILDING $packageName for Windows'), + contains('Windows is not supported by this plugin'), ]), ); @@ -361,16 +346,14 @@ void main() { pluginDirectory.childDirectory('example'); final List output = await runCapturingPrint( - runner, ['build-examples', '--no-ipa', '--windows']); + runner, ['build-examples', '--windows']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING Windows for $packageName', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + '\nBUILDING $packageName for Windows', ]), ); @@ -393,18 +376,16 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( - runner, ['build-examples', '--apk', '--no-ipa']); + final List output = + await runCapturingPrint(runner, ['build-examples', '--apk']); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ - '\nBUILDING APK for $packageName', - 'Android is not supported by this plugin', - '\n\n', - 'All builds successful!', + containsAllInOrder([ + contains('\nBUILDING APK for $packageName'), + contains('Android is not supported by this plugin'), ]), ); @@ -431,18 +412,14 @@ void main() { final List output = await runCapturingPrint(runner, [ 'build-examples', '--apk', - '--no-ipa', - '--no-macos', ]); final String packageName = p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, - orderedEquals([ + containsAllInOrder([ '\nBUILDING APK for $packageName', - '\n\n', - 'All builds successful!', ]), ); @@ -469,13 +446,8 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - await runCapturingPrint(runner, [ - 'build-examples', - '--apk', - '--no-ipa', - '--no-macos', - '--enable-experiment=exp1' - ]); + await runCapturingPrint(runner, + ['build-examples', '--apk', '--enable-experiment=exp1']); expect( processRunner.recordedCalls, @@ -502,12 +474,8 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - await runCapturingPrint(runner, [ - 'build-examples', - '--ipa', - '--no-macos', - '--enable-experiment=exp1' - ]); + await runCapturingPrint(runner, + ['build-examples', '--ios', '--enable-experiment=exp1']); expect( processRunner.recordedCalls, orderedEquals([ From 44f3d5d86a31663686f5afd379e6d17417f9751c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 30 Jun 2021 11:43:41 -0700 Subject: [PATCH 095/364] [flutter_plugin_tools] Migrate firebase-test-lab to new base command (#4116) Migrates firebase-test-lab to use the new package-looping base command. Other changes: - Extracts several helpers to make the main flow easier to follow - Removes support for finding and running `*_e2e.dart` files, since we no longer use that file structure for integration tests. Part of https://github.com/flutter/flutter/issues/83413 --- script/tool/CHANGELOG.md | 6 + .../lib/src/firebase_test_lab_command.dart | 285 ++++++++---------- ...rt => firebase_test_lab_command_test.dart} | 181 ++++++----- 3 files changed, 246 insertions(+), 226 deletions(-) rename script/tool/test/{firebase_test_lab_test.dart => firebase_test_lab_command_test.dart} (62%) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index a2716cb53d34..2b15ccdd2ac5 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,9 @@ +## NEXT + +- Modified the output format of many commands +- **Breaking change**: `firebase-test-lab` no longer supports `*_e2e.dart` + files, only `integration_test/*_test.dart`. + ## 0.3.0 - Add a --build-id flag to `firebase-test-lab` instead of hard-coding the use of diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index b4f5e92933c6..9f4982b2783e 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -10,18 +10,16 @@ import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; /// A command to run tests via Firebase test lab. -class FirebaseTestLabCommand extends PluginCommand { +class FirebaseTestLabCommand extends PackageLoopingCommand { /// Creates an instance of the test runner command. FirebaseTestLabCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - Print print = print, - }) : _print = print, - super(packagesDir, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner) { argParser.addOption( 'project', defaultsTo: 'flutter-infra', @@ -74,8 +72,6 @@ class FirebaseTestLabCommand extends PluginCommand { static const String _gradleWrapper = 'gradlew'; - final Print _print; - Completer? _firebaseProjectConfigured; Future _configureFirebaseProject() async { @@ -86,7 +82,7 @@ class FirebaseTestLabCommand extends PluginCommand { final String serviceKey = getStringArg('service-key'); if (serviceKey.isEmpty) { - _print('No --service-key provided; skipping gcloud authorization'); + print('No --service-key provided; skipping gcloud authorization'); } else { await processRunner.run( 'gcloud', @@ -105,10 +101,10 @@ class FirebaseTestLabCommand extends PluginCommand { getStringArg('project'), ]); if (exitCode == 0) { - _print('\nFirebase project configured.'); + print('\nFirebase project configured.'); return; } else { - _print( + print( '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.'); } } @@ -116,172 +112,155 @@ class FirebaseTestLabCommand extends PluginCommand { } @override - Future run() async { - final Stream packagesWithTests = getPackages().where( - (Directory d) => - isFlutterPackage(d) && - d - .childDirectory('example') - .childDirectory('android') - .childDirectory('app') - .childDirectory('src') - .childDirectory('androidTest') - .existsSync()); + Future> runForPackage(Directory package) async { + if (!package + .childDirectory('example') + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('androidTest') + .existsSync()) { + printSkip('No example with androidTest directory'); + return PackageLoopingCommand.success; + } - final List failingPackages = []; - final List missingFlutterBuild = []; - int resultsCounter = - 0; // We use a unique GCS bucket for each Firebase Test Lab run - await for (final Directory package in packagesWithTests) { - // See https://github.com/flutter/flutter/issues/38983 + final List errors = []; - final Directory exampleDirectory = package.childDirectory('example'); - final String packageName = - p.relative(package.path, from: packagesDir.path); - _print('\nRUNNING FIREBASE TEST LAB TESTS for $packageName'); + final Directory exampleDirectory = package.childDirectory('example'); + final Directory androidDirectory = + exampleDirectory.childDirectory('android'); - final Directory androidDirectory = - exampleDirectory.childDirectory('android'); + // Ensures that gradle wrapper exists + if (!await _ensureGradleWrapperExists(androidDirectory)) { + errors.add('Unable to build example apk'); + return errors; + } - final String enableExperiment = getStringArg(kEnableExperiment); - final String encodedEnableExperiment = - Uri.encodeComponent('--enable-experiment=$enableExperiment'); + await _configureFirebaseProject(); - // Ensures that gradle wrapper exists - if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { - final int exitCode = await processRunner.runAndStream( - 'flutter', - [ - 'build', - 'apk', - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ], - workingDir: androidDirectory); + if (!await _runGradle(androidDirectory, 'app:assembleAndroidTest')) { + errors.add('Unable to assemble androidTest'); + return errors; + } - if (exitCode != 0) { - failingPackages.add(packageName); - continue; - } + // Used within the loop to ensure a unique GCS output location for each + // test file's run. + int resultsCounter = 0; + for (final File test in _findIntegrationTestFiles(package)) { + final String testName = p.relative(test.path, from: package.path); + print('Testing $testName...'); + if (!await _runGradle(androidDirectory, 'app:assembleDebug', + testFile: test)) { + printError('Could not build $testName'); + errors.add('$testName failed to build'); continue; } + final String buildId = getStringArg('build-id'); + final String testRunId = getStringArg('test-run-id'); + final String resultsDir = + 'plugins_android_test/${getPackageDescription(package)}/$buildId/$testRunId/${resultsCounter++}/'; + final List args = [ + 'firebase', + 'test', + 'android', + 'run', + '--type', + 'instrumentation', + '--app', + 'build/app/outputs/apk/debug/app-debug.apk', + '--test', + 'build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk', + '--timeout', + '5m', + '--results-bucket=${getStringArg('results-bucket')}', + '--results-dir=$resultsDir', + ]; + for (final String device in getStringListArg('device')) { + args.addAll(['--device', device]); + } + final int exitCode = await processRunner.runAndStream('gcloud', args, + workingDir: exampleDirectory); - await _configureFirebaseProject(); + if (exitCode != 0) { + printError('Test failure for $testName'); + errors.add('$testName failed tests'); + } + } + return errors; + } - int exitCode = await processRunner.runAndStream( - p.join(androidDirectory.path, _gradleWrapper), + /// Checks that 'gradlew' exists in [androidDirectory], and if not runs a + /// Flutter build to generate it. + /// + /// Returns true if either gradlew was already present, or the build succeeds. + Future _ensureGradleWrapperExists(Directory androidDirectory) async { + if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { + print('Running flutter build apk...'); + final String experiment = getStringArg(kEnableExperiment); + final int exitCode = await processRunner.runAndStream( + 'flutter', [ - 'app:assembleAndroidTest', - '-Pverbose=true', - if (enableExperiment.isNotEmpty) - '-Pextra-front-end-options=$encodedEnableExperiment', - if (enableExperiment.isNotEmpty) - '-Pextra-gen-snapshot-options=$encodedEnableExperiment', + 'build', + 'apk', + if (experiment.isNotEmpty) '--enable-experiment=$experiment', ], workingDir: androidDirectory); if (exitCode != 0) { - failingPackages.add(packageName); - continue; - } - - // Look for tests recursively in folders that start with 'test' and that - // live in the root or example folders. - bool isTestDir(FileSystemEntity dir) { - return dir is Directory && - (p.basename(dir.path).startsWith('test') || - p.basename(dir.path) == 'integration_test'); + return false; } + } + return true; + } - final List testDirs = - package.listSync().where(isTestDir).cast().toList(); - final Directory example = package.childDirectory('example'); - testDirs.addAll( - example.listSync().where(isTestDir).cast().toList()); - for (final Directory testDir in testDirs) { - bool isE2ETest(FileSystemEntity file) { - return file.path.endsWith('_e2e.dart') || - (file.parent.basename == 'integration_test' && - file.path.endsWith('_test.dart')); - } - - final List testFiles = testDir - .listSync(recursive: true, followLinks: true) - .where(isE2ETest) - .toList(); - for (final FileSystemEntity test in testFiles) { - exitCode = await processRunner.runAndStream( - p.join(androidDirectory.path, _gradleWrapper), - [ - 'app:assembleDebug', - '-Pverbose=true', - '-Ptarget=${test.path}', - if (enableExperiment.isNotEmpty) - '-Pextra-front-end-options=$encodedEnableExperiment', - if (enableExperiment.isNotEmpty) - '-Pextra-gen-snapshot-options=$encodedEnableExperiment', - ], - workingDir: androidDirectory); + /// Builds [target] using 'gradlew' in the given [directory]. Assumes + /// 'gradlew' already exists. + /// + /// [testFile] optionally does the Flutter build with the given test file as + /// the build target. + /// + /// Returns true if the command succeeds. + Future _runGradle( + Directory directory, + String target, { + File? testFile, + }) async { + final String experiment = getStringArg(kEnableExperiment); + final String? extraOptions = experiment.isNotEmpty + ? Uri.encodeComponent('--enable-experiment=$experiment') + : null; - if (exitCode != 0) { - failingPackages.add(packageName); - continue; - } - final String buildId = getStringArg('build-id'); - final String testRunId = getStringArg('test-run-id'); - final String resultsDir = - 'plugins_android_test/$packageName/$buildId/$testRunId/${resultsCounter++}/'; - final List args = [ - 'firebase', - 'test', - 'android', - 'run', - '--type', - 'instrumentation', - '--app', - 'build/app/outputs/apk/debug/app-debug.apk', - '--test', - 'build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk', - '--timeout', - '5m', - '--results-bucket=${getStringArg('results-bucket')}', - '--results-dir=$resultsDir', - ]; - for (final String device in getStringListArg('device')) { - args.addAll(['--device', device]); - } - exitCode = await processRunner.runAndStream('gcloud', args, - workingDir: exampleDirectory); + final int exitCode = await processRunner.runAndStream( + p.join(directory.path, _gradleWrapper), + [ + target, + '-Pverbose=true', + if (testFile != null) '-Ptarget=${testFile.path}', + if (extraOptions != null) '-Pextra-front-end-options=$extraOptions', + if (extraOptions != null) + '-Pextra-gen-snapshot-options=$extraOptions', + ], + workingDir: directory); - if (exitCode != 0) { - failingPackages.add(packageName); - continue; - } - } - } + if (exitCode != 0) { + return false; } + return true; + } - _print('\n\n'); - if (failingPackages.isNotEmpty) { - _print( - 'The instrumentation tests for the following packages are failing (see above for' - 'details):'); - for (final String package in failingPackages) { - _print(' * $package'); - } - } - if (missingFlutterBuild.isNotEmpty) { - _print('Run "pub global run flutter_plugin_tools build-examples --apk" on' - 'the following packages before executing tests again:'); - for (final String package in missingFlutterBuild) { - _print(' * $package'); - } - } + /// Finds and returns all integration test files for [package]. + Iterable _findIntegrationTestFiles(Directory package) sync* { + final Directory integrationTestDir = + package.childDirectory('example').childDirectory('integration_test'); - if (failingPackages.isNotEmpty || missingFlutterBuild.isNotEmpty) { - throw ToolExit(1); + if (!integrationTestDir.existsSync()) { + return; } - _print('All Firebase Test Lab tests successful!'); + yield* integrationTestDir + .listSync(recursive: true, followLinks: true) + .where((FileSystemEntity file) => + file is File && file.basename.endsWith('_test.dart')) + .cast(); } } diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_command_test.dart similarity index 62% rename from script/tool/test/firebase_test_lab_test.dart rename to script/tool/test/firebase_test_lab_command_test.dart index 32867c949b4a..e317ba924bd1 100644 --- a/script/tool/test/firebase_test_lab_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -18,18 +18,15 @@ void main() { group('$FirebaseTestLabCommand', () { FileSystem fileSystem; late Directory packagesDir; - late List printedMessages; late CommandRunner runner; late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); - printedMessages = []; processRunner = RecordingProcessRunner(); - final FirebaseTestLabCommand command = FirebaseTestLabCommand(packagesDir, - processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString())); + final FirebaseTestLabCommand command = + FirebaseTestLabCommand(packagesDir, processRunner: processRunner); runner = CommandRunner( 'firebase_test_lab_command', 'Test for $FirebaseTestLabCommand'); @@ -48,32 +45,31 @@ void main() { 'example/should_not_run_e2e.dart', 'example/android/app/src/androidTest/MainActivityTest.java', ]); - await expectLater( - () => runCapturingPrint(runner, ['firebase-test-lab']), - throwsA(const TypeMatcher())); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['firebase-test-lab'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( - printedMessages, + output, contains( '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.')); }); - test('runs e2e tests', () async { + test('runs integration tests', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ 'test/plugin_test.dart', - 'test/plugin_e2e.dart', - 'should_not_run_e2e.dart', - 'lib/test/should_not_run_e2e.dart', - 'example/test/plugin_e2e.dart', - 'example/test_driver/plugin_e2e.dart', - 'example/test_driver/plugin_e2e_test.dart', + 'example/integration_test/bar_test.dart', 'example/integration_test/foo_test.dart', 'example/integration_test/should_not_run.dart', 'example/android/gradlew', - 'example/should_not_run_e2e.dart', 'example/android/app/src/androidTest/MainActivityTest.java', ]); - await runCapturingPrint(runner, [ + final List output = await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', 'model=flame,version=29', @@ -86,14 +82,17 @@ void main() { ]); expect( - printedMessages, - orderedEquals([ - '\nRUNNING FIREBASE TEST LAB TESTS for plugin', - '\nFirebase project configured.', - '\n\n', - 'All Firebase Test Lab tests successful!', + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Firebase project configured.'), + contains('Testing example/integration_test/bar_test.dart...'), + contains('Testing example/integration_test/foo_test.dart...'), ]), ); + expect(output, isNot(contains('test/plugin_test.dart'))); + expect(output, + isNot(contains('example/integration_test/should_not_run.dart'))); expect( processRunner.recordedCalls, @@ -111,7 +110,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/test/plugin_e2e.dart' + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/bar_test.dart' .split(' '), '/packages/plugin/example/android'), ProcessCall( @@ -121,7 +120,7 @@ void main() { '/packages/plugin/example'), ProcessCall( '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart' + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart' .split(' '), '/packages/plugin/example/android'), ProcessCall( @@ -129,16 +128,91 @@ void main() { 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), + ]), + ); + }); + + test('skips packages with no androidTest directory', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + ]); + + final List output = await runCapturingPrint(runner, [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No example with androidTest directory'), + ]), + ); + expect(output, + isNot(contains('Testing example/integration_test/foo_test.dart...'))); + + expect( + processRunner.recordedCalls, + orderedEquals([]), + ); + }); + + test('builds if gradlew is missing', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final List output = await runCapturingPrint(runner, [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Running flutter build apk...'), + contains('Firebase project configured.'), + contains('Testing example/integration_test/foo_test.dart...'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ ProcessCall( - '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart' - .split(' '), - '/packages/plugin/example/android'), + 'flutter', + 'build apk'.split(' '), + '/packages/plugin/example/android', + ), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/2/ --device model=flame,version=29 --device model=seoul,version=26' + 'auth activate-service-account --key-file=${Platform.environment['HOME']}/gcloud-service-key.json' .split(' '), - '/packages/plugin/example'), + null), + ProcessCall( + 'gcloud', 'config set project flutter-infra'.split(' '), null), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleAndroidTest -Pverbose=true'.split(' '), + '/packages/plugin/example/android'), ProcessCall( '/packages/plugin/example/android/gradlew', 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart' @@ -146,7 +220,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/3/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ]), @@ -155,17 +229,8 @@ void main() { test('experimental flag', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ - 'test/plugin_test.dart', - 'test/plugin_e2e.dart', - 'should_not_run_e2e.dart', - 'lib/test/should_not_run_e2e.dart', - 'example/test/plugin_e2e.dart', - 'example/test_driver/plugin_e2e.dart', - 'example/test_driver/plugin_e2e_test.dart', 'example/integration_test/foo_test.dart', - 'example/integration_test/should_not_run.dart', 'example/android/gradlew', - 'example/should_not_run_e2e.dart', 'example/android/app/src/androidTest/MainActivityTest.java', ]); @@ -195,36 +260,6 @@ void main() { 'app:assembleAndroidTest -Pverbose=true -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' .split(' '), '/packages/plugin/example/android'), - ProcessCall( - '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/test/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' - .split(' '), - '/packages/plugin/example/android'), - ProcessCall( - 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29' - .split(' '), - '/packages/plugin/example'), - ProcessCall( - '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' - .split(' '), - '/packages/plugin/example/android'), - ProcessCall( - 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29' - .split(' '), - '/packages/plugin/example'), - ProcessCall( - '/packages/plugin/example/android/gradlew', - 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' - .split(' '), - '/packages/plugin/example/android'), - ProcessCall( - 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/2/ --device model=flame,version=29' - .split(' '), - '/packages/plugin/example'), ProcessCall( '/packages/plugin/example/android/gradlew', 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' @@ -232,7 +267,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/3/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29' .split(' '), '/packages/plugin/example'), ]), From c36c0079b8856d7ec02ad91308d2934c95c8efb0 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 30 Jun 2021 12:16:46 -0700 Subject: [PATCH 096/364] [flutter_plugin_tools] Overhaul drive-examples (#4099) Significantly restructures drive-examples: - Migrates it to the new package-looping base command - Enforces that only one platform is passed, since in practice multiple platforms never actually worked. (The logic is structured so that it will be easy to enable multi-platform if `flutter drive` gains multi-platform support.) - Fixes the issue where `--ios` and `--android` were semi-broken, by doing explicit device targeting for them rather than relying on the default device being the right kind - Extracts much of the logic to helpers so it's easier to understand the flow - Removes support for a legacy integration test file structure that is no longer used - Adds more test coverage; previously no failure cases were actually tested. Fixes https://github.com/flutter/flutter/issues/85147 Part of https://github.com/flutter/flutter/issues/83413 --- .../tool/lib/src/build_examples_command.dart | 3 - .../tool/lib/src/common/plugin_command.dart | 5 + .../tool/lib/src/drive_examples_command.dart | 431 ++++++++------ .../test/drive_examples_command_test.dart | 559 ++++++++++++++---- 4 files changed, 692 insertions(+), 306 deletions(-) diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index c8280f4e867c..ee1445fa8b7f 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -6,7 +6,6 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -151,8 +150,6 @@ class BuildExamplesCommand extends PackageLoopingCommand { String flutterBuildType, { List extraBuildFlags = const [], }) async { - final String flutterCommand = - const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; final String enableExperiment = getStringArg(kEnableExperiment); final int exitCode = await processRunner.runAndStream( diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 9a96ab13443d..43d0d0b822c7 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -8,6 +8,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'core.dart'; import 'git_version_finder.dart'; @@ -85,6 +86,10 @@ abstract class PluginCommand extends Command { int? _shardIndex; int? _shardCount; + /// The command to use when running `flutter`. + String get flutterCommand => + const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; + /// The shard of the overall command execution that this instance should run. int get shardIndex { if (_shardIndex == null) { diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 8a8cd6726d02..a4aa7c12913d 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -2,17 +2,22 @@ // 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'; + import 'package:file/file.dart'; import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +const int _exitNoPlatformFlags = 2; +const int _exitNoAvailableDevice = 3; + /// A command to run the example applications for packages via Flutter driver. -class DriveExamplesCommand extends PluginCommand { +class DriveExamplesCommand extends PackageLoopingCommand { /// Creates an instance of the drive command. DriveExamplesCommand( Directory packagesDir, { @@ -43,213 +48,259 @@ class DriveExamplesCommand extends PluginCommand { @override final String description = 'Runs driver tests for plugin example apps.\n\n' - 'For each *_test.dart in test_driver/ it drives an application with a ' - 'corresponding name in the test/ or test_driver/ directories.\n\n' - 'For example, test_driver/app_test.dart would match test/app.dart.\n\n' - 'This command requires "flutter" to be in your path.\n\n' - 'If a file with a corresponding name cannot be found, this driver file' - 'will be used to drive the tests that match ' - 'integration_test/*_test.dart.'; + 'For each *_test.dart in test_driver/ it drives an application with ' + 'either the corresponding test in test_driver (for example, ' + 'test_driver/app_test.dart would match test_driver/app.dart), or the ' + '*_test.dart files in integration_test/.\n\n' + 'This command requires "flutter" to be in your path.'; + + Map> _targetDeviceFlags = const >{}; @override - Future run() async { - final List failingTests = []; - final List pluginsWithoutTests = []; - final bool isLinux = getBoolArg(kPlatformLinux); - final bool isMacos = getBoolArg(kPlatformMacos); - final bool isWeb = getBoolArg(kPlatformWeb); - final bool isWindows = getBoolArg(kPlatformWindows); - await for (final Directory plugin in getPlugins()) { - final String pluginName = plugin.basename; - if (pluginName.endsWith('_platform_interface') && - !plugin.childDirectory('example').existsSync()) { - // Platform interface packages generally aren't intended to have - // examples, and don't need integration tests, so silently skip them - // unless for some reason there is an example directory. - continue; + Future initializeRun() async { + final List platformSwitches = [ + kPlatformAndroid, + kPlatformIos, + kPlatformLinux, + kPlatformMacos, + kPlatformWeb, + kPlatformWindows, + ]; + final int platformCount = platformSwitches + .where((String platform) => getBoolArg(platform)) + .length; + // The flutter tool currently doesn't accept multiple device arguments: + // https://github.com/flutter/flutter/issues/35733 + // If that is implemented, this check can be relaxed. + if (platformCount != 1) { + printError( + 'Exactly one of ${platformSwitches.map((String platform) => '--$platform').join(', ')} ' + 'must be specified.'); + throw ToolExit(_exitNoPlatformFlags); + } + + String? androidDevice; + if (getBoolArg(kPlatformAndroid)) { + final List devices = await _getDevicesForPlatform('android'); + if (devices.isEmpty) { + printError('No Android devices available'); + throw ToolExit(_exitNoAvailableDevice); + } + androidDevice = devices.first; + } + + String? iosDevice; + if (getBoolArg(kPlatformIos)) { + final List devices = await _getDevicesForPlatform('ios'); + if (devices.isEmpty) { + printError('No iOS devices available'); + throw ToolExit(_exitNoAvailableDevice); + } + iosDevice = devices.first; + } + + _targetDeviceFlags = >{ + if (getBoolArg(kPlatformAndroid)) + kPlatformAndroid: ['-d', androidDevice!], + if (getBoolArg(kPlatformIos)) kPlatformIos: ['-d', iosDevice!], + if (getBoolArg(kPlatformLinux)) kPlatformLinux: ['-d', 'linux'], + if (getBoolArg(kPlatformMacos)) kPlatformMacos: ['-d', 'macos'], + if (getBoolArg(kPlatformWeb)) + kPlatformWeb: [ + '-d', + 'web-server', + '--web-port=7357', + '--browser-name=chrome' + ], + if (getBoolArg(kPlatformWindows)) + kPlatformWindows: ['-d', 'windows'], + }; + } + + @override + Future> runForPackage(Directory package) async { + if (package.basename.endsWith('_platform_interface') && + !package.childDirectory('example').existsSync()) { + // Platform interface packages generally aren't intended to have + // examples, and don't need integration tests, so skip rather than fail. + printSkip( + 'Platform interfaces are not expected to have integratino tests.'); + return PackageLoopingCommand.success; + } + + final List deviceFlags = []; + for (final MapEntry> entry + in _targetDeviceFlags.entries) { + if (pluginSupportsPlatform(entry.key, package)) { + deviceFlags.addAll(entry.value); + } else { + print('Skipping unsupported platform ${entry.key}...'); } - print('\n==========\nChecking $pluginName...'); - if (!(await _pluginSupportedOnCurrentPlatform(plugin))) { - print('Not supported for the target platform; skipping.'); + } + // If there is no supported target platform, skip the plugin. + if (deviceFlags.isEmpty) { + printSkip( + '${getPackageDescription(package)} does not support any requested platform.'); + return PackageLoopingCommand.success; + } + + int examplesFound = 0; + bool testsRan = false; + final List errors = []; + for (final Directory example in getExamplesForPlugin(package)) { + ++examplesFound; + final String exampleName = + p.relative(example.path, from: packagesDir.path); + + final List drivers = await _getDrivers(example); + if (drivers.isEmpty) { + print('No driver tests found for $exampleName'); continue; } - int examplesFound = 0; - bool testsRan = false; - final String flutterCommand = - const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; - for (final Directory example in getExamplesForPlugin(plugin)) { - ++examplesFound; - final String packageName = - p.relative(example.path, from: packagesDir.path); - final Directory driverTests = example.childDirectory('test_driver'); - if (!driverTests.existsSync()) { - print('No driver tests found for $packageName'); + + for (final File driver in drivers) { + final List testTargets = []; + + // Try to find a matching app to drive without the _test.dart + // TODO(stuartmorgan): Migrate all remaining uses of this legacy + // approach (currently only video_player) and remove support for it: + // https://github.com/flutter/flutter/issues/85224. + final File? legacyTestFile = _getLegacyTestFileForTestDriver(driver); + if (legacyTestFile != null) { + testTargets.add(legacyTestFile); + } else { + (await _getIntegrationTests(example)).forEach(testTargets.add); + } + + if (testTargets.isEmpty) { + final String driverRelativePath = + p.relative(driver.path, from: package.path); + printError( + 'Found $driverRelativePath, but no integration_test/*_test.dart files.'); + errors.add( + 'No test files for ${p.relative(driver.path, from: package.path)}'); continue; } - // Look for driver tests ending in _test.dart in test_driver/ - await for (final FileSystemEntity test in driverTests.list()) { - final String driverTestName = - p.relative(test.path, from: driverTests.path); - if (!driverTestName.endsWith('_test.dart')) { - continue; - } - // Try to find a matching app to drive without the _test.dart - final String deviceTestName = driverTestName.replaceAll( - RegExp(r'_test.dart$'), - '.dart', - ); - String deviceTestPath = p.join('test', deviceTestName); - if (!example.fileSystem - .file(p.join(example.path, deviceTestPath)) - .existsSync()) { - // If the app isn't in test/ folder, look in test_driver/ instead. - deviceTestPath = p.join('test_driver', deviceTestName); - } - - final List targetPaths = []; - if (example.fileSystem - .file(p.join(example.path, deviceTestPath)) - .existsSync()) { - targetPaths.add(deviceTestPath); - } else { - final Directory integrationTests = - example.childDirectory('integration_test'); - - if (await integrationTests.exists()) { - await for (final FileSystemEntity integrationTest - in integrationTests.list()) { - if (!integrationTest.basename.endsWith('_test.dart')) { - continue; - } - targetPaths - .add(p.relative(integrationTest.path, from: example.path)); - } - } - - if (targetPaths.isEmpty) { - print(''' -Unable to infer a target application for $driverTestName to drive. -Tried searching for the following: -1. test/$deviceTestName -2. test_driver/$deviceTestName -3. test_driver/*_test.dart -'''); - failingTests.add(p.relative(test.path, from: example.path)); - continue; - } - } - - final List driveArgs = ['drive']; - - final String enableExperiment = getStringArg(kEnableExperiment); - if (enableExperiment.isNotEmpty) { - driveArgs.add('--enable-experiment=$enableExperiment'); - } - - if (isLinux && isLinuxPlugin(plugin)) { - driveArgs.addAll([ - '-d', - 'linux', - ]); - } - if (isMacos && isMacOsPlugin(plugin)) { - driveArgs.addAll([ - '-d', - 'macos', - ]); - } - if (isWeb && isWebPlugin(plugin)) { - driveArgs.addAll([ - '-d', - 'web-server', - '--web-port=7357', - '--browser-name=chrome', - ]); - } - if (isWindows && isWindowsPlugin(plugin)) { - driveArgs.addAll([ - '-d', - 'windows', - ]); - } - - for (final String targetPath in targetPaths) { - testsRan = true; - final int exitCode = await processRunner.runAndStream( - flutterCommand, - [ - ...driveArgs, - '--driver', - p.join('test_driver', driverTestName), - '--target', - targetPath, - ], - workingDir: example, - exitOnError: true); - if (exitCode != 0) { - failingTests.add(p.join(packageName, deviceTestPath)); - } - } + + testsRan = true; + final List failingTargets = await _driveTests( + example, driver, testTargets, + deviceFlags: deviceFlags); + for (final File failingTarget in failingTargets) { + errors.add(p.relative(failingTarget.path, from: package.path)); } } - if (!testsRan) { - pluginsWithoutTests.add(pluginName); - print( - 'No driver tests run for $pluginName ($examplesFound examples found)'); - } } - print('\n\n'); + if (!testsRan) { + printError('No driver tests were run ($examplesFound example(s) found).'); + errors.add('No tests ran (use --exclude if this is intentional).'); + } + return errors; + } + + Future> _getDevicesForPlatform(String platform) async { + final List deviceIds = []; - if (failingTests.isNotEmpty) { - print('The following driver tests are failing (see above for details):'); - for (final String test in failingTests) { - print(' * $test'); + final ProcessResult result = await processRunner.run( + flutterCommand, ['devices', '--machine'], + stdoutEncoding: utf8, exitOnError: true); + if (result.exitCode != 0) { + return deviceIds; + } + + final List> devices = + (jsonDecode(result.stdout as String) as List) + .cast>(); + for (final Map deviceInfo in devices) { + final String targetPlatform = + (deviceInfo['targetPlatform'] as String?) ?? ''; + if (targetPlatform.startsWith(platform)) { + final String? deviceId = deviceInfo['id'] as String?; + if (deviceId != null) { + deviceIds.add(deviceId); + } } - throw ToolExit(1); } + return deviceIds; + } + + Future> _getDrivers(Directory example) async { + final List drivers = []; - if (pluginsWithoutTests.isNotEmpty) { - print('The following plugins did not run any integration tests:'); - for (final String plugin in pluginsWithoutTests) { - print(' * $plugin'); + final Directory driverDir = example.childDirectory('test_driver'); + if (driverDir.existsSync()) { + await for (final FileSystemEntity driver in driverDir.list()) { + if (driver is File && driver.basename.endsWith('_test.dart')) { + drivers.add(driver); + } } - print('If this is intentional, they must be explicitly excluded.'); - throw ToolExit(1); } + return drivers; + } + + File? _getLegacyTestFileForTestDriver(File testDriver) { + final String testName = testDriver.basename.replaceAll( + RegExp(r'_test.dart$'), + '.dart', + ); + final File testFile = testDriver.parent.childFile(testName); - print('All driver tests successful!'); + return testFile.existsSync() ? testFile : null; } - Future _pluginSupportedOnCurrentPlatform( - FileSystemEntity plugin) async { - final bool isAndroid = getBoolArg(kPlatformAndroid); - final bool isIOS = getBoolArg(kPlatformIos); - final bool isLinux = getBoolArg(kPlatformLinux); - final bool isMacos = getBoolArg(kPlatformMacos); - final bool isWeb = getBoolArg(kPlatformWeb); - final bool isWindows = getBoolArg(kPlatformWindows); - if (isAndroid) { - return isAndroidPlugin(plugin); - } - if (isIOS) { - return isIosPlugin(plugin); - } - if (isLinux) { - return isLinuxPlugin(plugin); - } - if (isMacos) { - return isMacOsPlugin(plugin); - } - if (isWeb) { - return isWebPlugin(plugin); + Future> _getIntegrationTests(Directory example) async { + final List tests = []; + final Directory integrationTestDir = + example.childDirectory('integration_test'); + + if (integrationTestDir.existsSync()) { + await for (final FileSystemEntity file in integrationTestDir.list()) { + if (file is File && file.basename.endsWith('_test.dart')) { + tests.add(file); + } + } } - if (isWindows) { - return isWindowsPlugin(plugin); + return tests; + } + + /// For each file in [targets], uses + /// `flutter drive --driver [driver] --target ` + /// to drive [example], returning a list of any failing test targets. + /// + /// [deviceFlags] should contain the flags to run the test on a specific + /// target device (plus any supporting device-specific flags). E.g.: + /// - `['-d', 'macos']` for driving for macOS. + /// - `['-d', 'web-server', '--web-port=', '--browser-name=]` + /// for web + Future> _driveTests( + Directory example, + File driver, + List targets, { + required List deviceFlags, + }) async { + final List failures = []; + + final String enableExperiment = getStringArg(kEnableExperiment); + + for (final File target in targets) { + final int exitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'drive', + ...deviceFlags, + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + '--driver', + p.relative(driver.path, from: example.path), + '--target', + p.relative(target.path, from: example.path), + ], + workingDir: example, + exitOnError: true); + if (exitCode != 0) { + failures.add(target); + } } - // When we are here, no flags are specified. Only return true if the plugin - // supports Android for legacy command support. - // TODO(cyanglaz): Make Android flag also required like other platforms - // (breaking change). https://github.com/flutter/flutter/issues/58285 - return isAndroidPlugin(plugin); + return failures; } } diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 3175f7163546..e441a0f68d9e 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -12,8 +12,12 @@ import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; +const String _fakeIosDevice = '67d5c3d1-8bdf-46ad-8f6b-b00e2a972dda'; +const String _fakeAndroidDevice = 'emulator-1234'; + void main() { group('test drive_example_command', () { late FileSystem fileSystem; @@ -35,52 +39,92 @@ void main() { runner.addCommand(command); }); - test('driving under folder "test"', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test_driver/plugin_test.dart', - 'example/test/plugin.dart', - ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - }, + void setMockFlutterDevicesOutput({ + bool hasIosDevice = true, + bool hasAndroidDevice = true, + }) { + final List devices = [ + if (hasIosDevice) '{"id": "$_fakeIosDevice", "targetPlatform": "ios"}', + if (hasAndroidDevice) + '{"id": "$_fakeAndroidDevice", "targetPlatform": "android-x86"}', + ]; + final String output = '''[${devices.join(',')}]'''; + + final MockProcess mockDevicesProcess = MockProcess(); + mockDevicesProcess.exitCodeCompleter.complete(0); + mockDevicesProcess.stdoutController.close(); // ignore: unawaited_futures + processRunner.processToReturn = mockDevicesProcess; + processRunner.resultStdout = output; + } + + test('fails if no platforms are provided', () async { + setMockFlutterDevicesOutput(); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Exactly one of'), + ]), ); + }); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); + test('fails if multiple platforms are provided', () async { + setMockFlutterDevicesOutput(); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--ios', '--macos'], + errorHandler: (Error e) { + commandError = e; + }); - final List output = await runCapturingPrint(runner, [ - 'drive-examples', - ]); + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Exactly one of'), + ]), + ); + }); + + test('fails for iOS if no iOS devices are present', () async { + setMockFlutterDevicesOutput(hasIosDevice: false); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--ios'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('No iOS devices'), ]), ); + }); - final String deviceTestPath = p.join('test', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); + test('fails if Android if no Android devices are present', () async { + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - flutterCommand, - [ - 'drive', - '--driver', - driverTestPath, - '--target', - deviceTestPath - ], - pluginExampleDirectory.path), - ])); + output, + containsAllInOrder([ + contains('No Android devices'), + ]), + ); }); test('driving under folder "test_driver"', () async { @@ -100,16 +144,15 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint(runner, [ - 'drive-examples', - ]); + setMockFlutterDevicesOutput(); + final List output = + await runCapturingPrint(runner, ['drive-examples', '--ios']); expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -118,10 +161,14 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall( + flutterCommand, const ['devices', '--machine'], null), ProcessCall( flutterCommand, [ 'drive', + '-d', + _fakeIosDevice, '--driver', driverTestPath, '--target', @@ -133,6 +180,7 @@ void main() { test('driving under folder "test_driver" when test files are missing"', () async { + setMockFlutterDevicesOutput(); createFakePlugin( 'plugin', packagesDir, @@ -145,13 +193,27 @@ void main() { }, ); - await expectLater( - () => runCapturingPrint(runner, ['drive-examples']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No driver tests were run (1 example(s) found).'), + contains('No test files for example/test_driver/plugin_test.dart'), + ]), + ); }); test('a plugin without any integration test files is reported as an error', () async { + setMockFlutterDevicesOutput(); createFakePlugin( 'plugin', packagesDir, @@ -164,9 +226,22 @@ void main() { }, ); - await expectLater( - () => runCapturingPrint(runner, ['drive-examples']), - throwsA(const TypeMatcher())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No driver tests were run (1 example(s) found).'), + contains('No tests ran'), + ]), + ); }); test( @@ -190,16 +265,15 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint(runner, [ - 'drive-examples', - ]); + setMockFlutterDevicesOutput(); + final List output = + await runCapturingPrint(runner, ['drive-examples', '--ios']); expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -208,10 +282,14 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall( + flutterCommand, const ['devices', '--machine'], null), ProcessCall( flutterCommand, [ 'drive', + '-d', + _fakeIosDevice, '--driver', driverTestPath, '--target', @@ -222,6 +300,8 @@ void main() { flutterCommand, [ 'drive', + '-d', + _fakeIosDevice, '--driver', driverTestPath, '--target', @@ -244,11 +324,10 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - 'Not supported for the target platform; skipping.', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('Skipping unsupported platform linux...'), + contains('No issues found!'), ]), ); @@ -280,10 +359,9 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -320,11 +398,10 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - 'Not supported for the target platform; skipping.', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('Skipping unsupported platform macos...'), + contains('No issues found!'), ]), ); @@ -332,6 +409,7 @@ void main() { // implementation is a no-op. expect(processRunner.recordedCalls, []); }); + test('driving on a macOS plugin', () async { final Directory pluginDirectory = createFakePlugin( 'plugin', @@ -356,10 +434,9 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -396,11 +473,9 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - 'Not supported for the target platform; skipping.', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -432,10 +507,9 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -474,11 +548,10 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - 'Not supported for the target platform; skipping.', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('Skipping unsupported platform windows...'), + contains('No issues found!'), ]), ); @@ -510,10 +583,9 @@ void main() { expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); @@ -537,8 +609,8 @@ void main() { ])); }); - test('driving when plugin does not support mobile is no-op', () async { - createFakePlugin( + test('driving on an Android plugin', () async { + final Directory pluginDirectory = createFakePlugin( 'plugin', packagesDir, extraFiles: [ @@ -546,47 +618,134 @@ void main() { 'example/test_driver/plugin.dart', ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, + kPlatformAndroid: PlatformSupport.inline, }, ); + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + setMockFlutterDevicesOutput(); final List output = await runCapturingPrint(runner, [ 'drive-examples', + '--android', ]); expect( output, - orderedEquals([ - '\n==========\nChecking plugin...', - 'Not supported for the target platform; skipping.', - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), ]), ); - // Output should be empty since running drive-examples --macos with no macos - // implementation is a no-op. - expect(processRunner.recordedCalls, []); + final String deviceTestPath = p.join('test_driver', 'plugin.dart'); + final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + flutterCommand, const ['devices', '--machine'], null), + ProcessCall( + flutterCommand, + [ + 'drive', + '-d', + _fakeAndroidDevice, + '--driver', + driverTestPath, + '--target', + deviceTestPath + ], + pluginExampleDirectory.path), + ])); + }); + + test('driving when plugin does not support Android is no-op', () async { + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }, + ); + + setMockFlutterDevicesOutput(); + final List output = await runCapturingPrint( + runner, ['drive-examples', '--android']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Skipping unsupported platform android...'), + contains('No issues found!'), + ]), + ); + + // Output should be empty other than the device query. + expect(processRunner.recordedCalls, [ + ProcessCall( + flutterCommand, const ['devices', '--machine'], null), + ]); + }); + + test('driving when plugin does not support iOS is no-op', () async { + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }, + ); + + setMockFlutterDevicesOutput(); + final List output = + await runCapturingPrint(runner, ['drive-examples', '--ios']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Skipping unsupported platform ios...'), + contains('No issues found!'), + ]), + ); + + // Output should be empty other than the device query. + expect(processRunner.recordedCalls, [ + ProcessCall( + flutterCommand, const ['devices', '--machine'], null), + ]); }); test('platform interface plugins are silently skipped', () async { createFakePlugin('aplugin_platform_interface', packagesDir, examples: []); - final List output = await runCapturingPrint(runner, [ - 'drive-examples', - ]); + setMockFlutterDevicesOutput(); + final List output = await runCapturingPrint( + runner, ['drive-examples', '--macos']); expect( output, - orderedEquals([ - '\n\n', - 'All driver tests successful!', + containsAllInOrder([ + contains('Running for aplugin_platform_interface'), + contains( + 'SKIPPING: Platform interfaces are not expected to have integratino tests.'), + contains('No issues found!'), ]), ); - // Output should be empty since running drive-examples --macos with no macos - // implementation is a no-op. + // Output should be empty since it's skipped. expect(processRunner.recordedCalls, []); }); @@ -596,7 +755,7 @@ void main() { packagesDir, extraFiles: [ 'example/test_driver/plugin_test.dart', - 'example/test/plugin.dart', + 'example/test_driver/plugin.dart', ], platformSupport: { kPlatformAndroid: PlatformSupport.inline, @@ -607,20 +766,26 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); + setMockFlutterDevicesOutput(); await runCapturingPrint(runner, [ 'drive-examples', + '--ios', '--enable-experiment=exp1', ]); - final String deviceTestPath = p.join('test', 'plugin.dart'); + final String deviceTestPath = p.join('test_driver', 'plugin.dart'); final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall( + flutterCommand, const ['devices', '--machine'], null), ProcessCall( flutterCommand, [ 'drive', + '-d', + _fakeIosDevice, '--enable-experiment=exp1', '--driver', driverTestPath, @@ -630,5 +795,173 @@ void main() { pluginExampleDirectory.path), ])); }); + + test('fails when no example is present', () async { + createFakePlugin( + 'plugin', + packagesDir, + examples: [], + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }, + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--web'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No driver tests were run (0 example(s) found).'), + contains('The following packages had errors:'), + contains(' plugin:\n' + ' No tests ran (use --exclude if this is intentional)'), + ]), + ); + }); + + test('fails when no driver is present', () async { + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/integration_test/bar_test.dart', + 'example/integration_test/foo_test.dart', + ], + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }, + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--web'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No driver tests found for plugin/example'), + contains('No driver tests were run (1 example(s) found).'), + contains('The following packages had errors:'), + contains(' plugin:\n' + ' No tests ran (use --exclude if this is intentional)'), + ]), + ); + }); + + test('fails when no integration tests are present', () async { + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/integration_test.dart', + ], + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }, + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--web'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Found example/test_driver/integration_test.dart, but no ' + 'integration_test/*_test.dart files.'), + contains('No driver tests were run (1 example(s) found).'), + contains('The following packages had errors:'), + contains(' plugin:\n' + ' No test files for example/test_driver/integration_test.dart\n' + ' No tests ran (use --exclude if this is intentional)'), + ]), + ); + }); + + test('reports test failures', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/integration_test.dart', + 'example/integration_test/bar_test.dart', + 'example/integration_test/foo_test.dart', + ], + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }, + ); + + // Simulate failure from `flutter drive`. + final MockProcess mockDriveProcess = MockProcess(); + mockDriveProcess.exitCodeCompleter.complete(1); + processRunner.processToReturn = mockDriveProcess; + + Error? commandError; + final List output = + await runCapturingPrint(runner, ['drive-examples', '--macos'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('The following packages had errors:'), + contains(' plugin:\n' + ' example/integration_test/bar_test.dart\n' + ' example/integration_test/foo_test.dart'), + ]), + ); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + final String driverTestPath = + p.join('test_driver', 'integration_test.dart'); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + flutterCommand, + [ + 'drive', + '-d', + 'macos', + '--driver', + driverTestPath, + '--target', + p.join('integration_test', 'bar_test.dart'), + ], + pluginExampleDirectory.path), + ProcessCall( + flutterCommand, + [ + 'drive', + '-d', + 'macos', + '--driver', + driverTestPath, + '--target', + p.join('integration_test', 'foo_test.dart'), + ], + pluginExampleDirectory.path), + ])); + }); }); } From 893ba4981c26377823894dfd3b927aabd61b1306 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 1 Jul 2021 13:11:05 +0200 Subject: [PATCH 097/364] [in_app_purchase] Added country code to sk product wrapper (#4122) --- .../in_app_purchase_ios/CHANGELOG.md | 4 ++++ .../example/ios/RunnerTests/TranslatorTests.m | 3 ++- .../ios/Classes/FIAObjectTranslator.m | 1 + .../store_kit_wrappers/sk_product_wrapper.dart | 14 +++++++++++--- .../sk_product_wrapper.g.dart | 2 ++ .../in_app_purchase_ios/pubspec.yaml | 2 +- .../sk_methodchannel_apis_test.dart | 4 ++++ .../store_kit_wrappers/sk_product_test.dart | 18 ++++++++++++++---- .../sk_test_stub_objects.dart | 10 +++++++--- 9 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index f6acc2b6d6ce..acbe995877dd 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.2 + +* Added countryCode to the SKPriceLocaleWrapper. + ## 0.1.1+1 * iOS: Fix treating missing App Store receipt as an exception. diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m index 42c51b846857..89a7b2c84380 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/TranslatorTests.m @@ -149,9 +149,10 @@ - (void)testError { - (void)testLocaleToMap { if (@available(iOS 10.0, *)) { - NSLocale *system = NSLocale.systemLocale; + NSLocale *system = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US"]; NSDictionary *map = [FIAObjectTranslator getMapFromNSLocale:system]; XCTAssertEqualObjects(map[@"currencySymbol"], system.currencySymbol); + XCTAssertEqualObjects(map[@"countryCode"], system.countryCode); } } diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m index 30b0b812da15..765ef4dd88d9 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m @@ -111,6 +111,7 @@ + (NSDictionary *)getMapFromNSLocale:(NSLocale *)locale { forKey:@"currencySymbol"]; [map setObject:[locale objectForKey:NSLocaleCurrencyCode] ?: [NSNull null] forKey:@"currencyCode"]; + [map setObject:[locale objectForKey:NSLocaleCountryCode] ?: [NSNull null] forKey:@"countryCode"]; return map; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart index ef0e6671d177..1b681f24f8db 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -335,15 +335,19 @@ class SKProductWrapper { @JsonSerializable() class SKPriceLocaleWrapper { /// Creates a new price locale for `currencySymbol` and `currencyCode`. - SKPriceLocaleWrapper( - {required this.currencySymbol, required this.currencyCode}); + SKPriceLocaleWrapper({ + required this.currencySymbol, + required this.currencyCode, + required this.countryCode, + }); /// Constructing an instance from a map from the Objective-C layer. /// /// This method should only be used with `map` values returned by [SKProductWrapper.fromJson] and [SKProductDiscountWrapper.fromJson]. factory SKPriceLocaleWrapper.fromJson(Map? map) { if (map == null) { - return SKPriceLocaleWrapper(currencyCode: '', currencySymbol: ''); + return SKPriceLocaleWrapper( + currencyCode: '', currencySymbol: '', countryCode: ''); } return _$SKPriceLocaleWrapperFromJson(map); } @@ -356,6 +360,10 @@ class SKPriceLocaleWrapper { @JsonKey(defaultValue: '') final String currencyCode; + ///The country code for the locale, e.g. US for US locale. + @JsonKey(defaultValue: '') + final String countryCode; + @override bool operator ==(Object other) { if (identical(other, this)) { diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart index 8c2eed3d6070..66f4b7827c38 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart @@ -112,6 +112,7 @@ SKPriceLocaleWrapper _$SKPriceLocaleWrapperFromJson(Map json) { return SKPriceLocaleWrapper( currencySymbol: json['currencySymbol'] as String? ?? '', currencyCode: json['currencyCode'] as String? ?? '', + countryCode: json['countryCode'] as String? ?? '', ); } @@ -120,4 +121,5 @@ Map _$SKPriceLocaleWrapperToJson( { 'currencySymbol': instance.currencySymbol, 'currencyCode': instance.currencyCode, + 'countryCode': instance.countryCode, }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index d06e5fef0bd4..b73ad8492647 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.1+1 +version: 0.1.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index 7bfddaa0a32a..892b9d346ada 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -47,6 +47,10 @@ void main() { productResponseWrapper.products.first.priceLocale.currencyCode, 'USD', ); + expect( + productResponseWrapper.products.first.priceLocale.countryCode, + 'US', + ); expect( productResponseWrapper.invalidProductIdentifiers, isNotEmpty, diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart index 9454a9d4ebee..6233a71be135 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart @@ -44,8 +44,13 @@ void main() { final SKProductDiscountWrapper wrapper = SKProductDiscountWrapper.fromJson({}); expect(wrapper.price, ''); - expect(wrapper.priceLocale, - SKPriceLocaleWrapper(currencyCode: '', currencySymbol: '')); + expect( + wrapper.priceLocale, + SKPriceLocaleWrapper( + currencyCode: '', + currencySymbol: '', + countryCode: '', + )); expect(wrapper.numberOfPeriods, 0); expect(wrapper.paymentMode, SKProductDiscountPaymentMode.payAsYouGo); expect( @@ -69,8 +74,13 @@ void main() { expect(wrapper.productIdentifier, ''); expect(wrapper.localizedTitle, ''); expect(wrapper.localizedDescription, ''); - expect(wrapper.priceLocale, - SKPriceLocaleWrapper(currencyCode: '', currencySymbol: '')); + expect( + wrapper.priceLocale, + SKPriceLocaleWrapper( + currencyCode: '', + currencySymbol: '', + countryCode: '', + )); expect(wrapper.subscriptionGroupIdentifier, null); expect(wrapper.price, ''); expect(wrapper.subscriptionPeriod, null); diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart index d6c24460761e..435dd44fdd8e 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart @@ -33,8 +33,11 @@ final SKPaymentTransactionWrapper dummyTransaction = error: dummyError, ); -final SKPriceLocaleWrapper dummyLocale = - SKPriceLocaleWrapper(currencySymbol: '\$', currencyCode: 'USD'); +final SKPriceLocaleWrapper dummyLocale = SKPriceLocaleWrapper( + currencySymbol: '\$', + currencyCode: 'USD', + countryCode: 'US', +); final SKProductSubscriptionPeriodWrapper dummySubscription = SKProductSubscriptionPeriodWrapper( @@ -70,7 +73,8 @@ final SkProductResponseWrapper dummyProductResponseWrapper = Map buildLocaleMap(SKPriceLocaleWrapper local) { return { 'currencySymbol': local.currencySymbol, - 'currencyCode': local.currencyCode + 'currencyCode': local.currencyCode, + 'countryCode': local.countryCode, }; } From 962c60701d663c5cc3c85f22d193351e46fdb157 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 1 Jul 2021 12:11:18 -0700 Subject: [PATCH 098/364] Exclude arm64 simulators from google_maps_flutter example project (#4127) --- packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md | 1 + .../google_maps_flutter/example/ios/Podfile | 3 +++ .../example/ios/Runner.xcodeproj/project.pbxproj | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 5ba399311661..04be1b915a5a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Add iOS unit and UI integration test targets. +* Exclude arm64 simulators in example app. ## 2.0.6 diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile b/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile index 3924e59aa0f9..9686afaf3c99 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile @@ -37,5 +37,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |build_configuration| + build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64 i386' + end end end diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 2093134f0bfb..cfaff19656f2 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -599,6 +599,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -620,6 +621,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", From 6425a3056bf5e057c62b3329d1ba28ba911ea61d Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 1 Jul 2021 13:09:48 -0700 Subject: [PATCH 099/364] Exclude arm64 simulators from google_sign_in example project (#4128) --- packages/google_sign_in/google_sign_in/CHANGELOG.md | 1 + packages/google_sign_in/google_sign_in/example/ios/Podfile | 4 ++++ .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index ce0849845e40..cb4a65f42fa2 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Add iOS unit and UI integration test targets. +* Exclude arm64 simulators in example app. ## 5.0.4 diff --git a/packages/google_sign_in/google_sign_in/example/ios/Podfile b/packages/google_sign_in/google_sign_in/example/ios/Podfile index 60e9fb54baa5..e577a3081fe8 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Podfile +++ b/packages/google_sign_in/google_sign_in/example/ios/Podfile @@ -39,5 +39,9 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |build_configuration| + # GoogleSignIn does not support arm64 simulators. + build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64 i386' + end end end diff --git a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj index 143457fc5acb..0c3cc430d23e 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj @@ -603,6 +603,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -624,6 +625,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -645,6 +647,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; @@ -659,6 +662,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; INFOPLIST_FILE = RunnerTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; From 719a6753aee626b65b0748230c4295579fc3d6c1 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 1 Jul 2021 16:25:21 -0700 Subject: [PATCH 100/364] [flutter_plugin_tools] Add a summary for successful runs (#4118) Add a summary to the end of successful runs for everything using the new looping base command, similar to what we do for summarizing failures. This will make it easy to manually check results for PRs that we know should be changing the set of run packages (adding a new package, adding a new test type to a package, adding a new test type to the tool), as well as spot-checking when we see unexpected results (e.g., looking back and why a PR didn't fail CI when we discover that it should have). To support better surfacing skips, this restructures the return value of `runForPackage` to have "skip" as one of the options. As a result of it being a return value, packages that used `printSkip` to indicate that *parts* of the command were being skipped have been changed to no longer do that. Fixes https://github.com/flutter/flutter/issues/85626 --- script/tool/CHANGELOG.md | 2 + script/tool/lib/src/analyze_command.dart | 6 +- .../tool/lib/src/build_examples_command.dart | 180 ++++++++------ script/tool/lib/src/common/core.dart | 10 - .../src/common/package_looping_command.dart | 201 ++++++++++++--- .../tool/lib/src/drive_examples_command.dart | 14 +- .../lib/src/firebase_test_lab_command.dart | 26 +- script/tool/lib/src/java_test_command.dart | 10 +- .../tool/lib/src/lint_podspecs_command.dart | 14 +- .../tool/lib/src/pubspec_check_command.dart | 6 +- .../tool/lib/src/version_check_command.dart | 23 +- script/tool/lib/src/xctest_command.dart | 53 ++-- .../test/build_examples_command_test.dart | 67 ++--- .../common/package_looping_command_test.dart | 198 +++++++++++++-- .../test/drive_examples_command_test.dart | 2 +- .../test/firebase_test_lab_command_test.dart | 11 +- script/tool/test/java_test_command_test.dart | 16 ++ .../tool/test/lint_podspecs_command_test.dart | 13 + script/tool/test/xctest_command_test.dart | 233 ++++++++++++++++-- 19 files changed, 814 insertions(+), 271 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 2b15ccdd2ac5..d64655f5b868 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -3,6 +3,8 @@ - Modified the output format of many commands - **Breaking change**: `firebase-test-lab` no longer supports `*_e2e.dart` files, only `integration_test/*_test.dart`. +- Add a summary to the end of successful command runs for commands using the + new output format. ## 0.3.0 diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 3f6a2444ad9b..29e78f3ea4ff 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -106,13 +106,13 @@ class AnalyzeCommand extends PackageLoopingCommand { } @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { final int exitCode = await processRunner.runAndStream( _dartBinaryPath, ['analyze', '--fatal-infos'], workingDir: package); if (exitCode != 0) { - return PackageLoopingCommand.failure; + return PackageResult.fail(); } - return PackageLoopingCommand.success; + return PackageResult.success(); } } diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index ee1445fa8b7f..32905c83db91 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -37,6 +37,43 @@ class BuildExamplesCommand extends PackageLoopingCommand { ); } + // Maps the switch this command uses to identify a platform to information + // about it. + static final Map _platforms = + { + _platformFlagApk: const _PlatformDetails( + 'Android', + pluginPlatform: kPlatformAndroid, + flutterBuildType: 'apk', + ), + kPlatformIos: const _PlatformDetails( + 'iOS', + pluginPlatform: kPlatformIos, + flutterBuildType: 'ios', + extraBuildFlags: ['--no-codesign'], + ), + kPlatformLinux: const _PlatformDetails( + 'Linux', + pluginPlatform: kPlatformLinux, + flutterBuildType: 'linux', + ), + kPlatformMacos: const _PlatformDetails( + 'macOS', + pluginPlatform: kPlatformMacos, + flutterBuildType: 'macos', + ), + kPlatformWeb: const _PlatformDetails( + 'web', + pluginPlatform: kPlatformWeb, + flutterBuildType: 'web', + ), + kPlatformWindows: const _PlatformDetails( + 'Windows', + pluginPlatform: kPlatformWindows, + flutterBuildType: 'windows', + ), + }; + @override final String name = 'build-examples'; @@ -47,102 +84,67 @@ class BuildExamplesCommand extends PackageLoopingCommand { @override Future initializeRun() async { - final List platformSwitches = [ - _platformFlagApk, - kPlatformIos, - kPlatformLinux, - kPlatformMacos, - kPlatformWeb, - kPlatformWindows, - ]; - if (!platformSwitches.any((String platform) => getBoolArg(platform))) { + final List platformFlags = _platforms.keys.toList(); + platformFlags.sort(); + if (!platformFlags.any((String platform) => getBoolArg(platform))) { printError( - 'None of ${platformSwitches.map((String platform) => '--$platform').join(', ')} ' + 'None of ${platformFlags.map((String platform) => '--$platform').join(', ')} ' 'were specified. At least one platform must be provided.'); throw ToolExit(_exitNoPlatformFlags); } } @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { final List errors = []; + final Iterable<_PlatformDetails> requestedPlatforms = _platforms.entries + .where( + (MapEntry entry) => getBoolArg(entry.key)) + .map((MapEntry entry) => entry.value); + final Set<_PlatformDetails> buildPlatforms = <_PlatformDetails>{}; + final Set<_PlatformDetails> unsupportedPlatforms = <_PlatformDetails>{}; + for (final _PlatformDetails platform in requestedPlatforms) { + if (pluginSupportsPlatform(platform.pluginPlatform, package)) { + buildPlatforms.add(platform); + } else { + unsupportedPlatforms.add(platform); + } + } + if (buildPlatforms.isEmpty) { + final String unsupported = requestedPlatforms.length == 1 + ? '${requestedPlatforms.first.label} is not supported' + : 'None of [${requestedPlatforms.map((_PlatformDetails p) => p.label).join(',')}] are supported'; + return PackageResult.skip('$unsupported by this plugin'); + } + print('Building for: ' + '${buildPlatforms.map((_PlatformDetails platform) => platform.label).join(',')}'); + if (unsupportedPlatforms.isNotEmpty) { + print('Skipping unsupported platform(s): ' + '${unsupportedPlatforms.map((_PlatformDetails platform) => platform.label).join(',')}'); + } + print(''); + for (final Directory example in getExamplesForPlugin(package)) { final String packageName = p.relative(example.path, from: packagesDir.path); - if (getBoolArg(kPlatformLinux)) { - print('\nBUILDING $packageName for Linux'); - if (isLinuxPlugin(package)) { - if (!await _buildExample(example, kPlatformLinux)) { - errors.add('$packageName (Linux)'); - } - } else { - printSkip('Linux is not supported by this plugin'); - } - } - - if (getBoolArg(kPlatformMacos)) { - print('\nBUILDING $packageName for macOS'); - if (isMacOsPlugin(package)) { - if (!await _buildExample(example, kPlatformMacos)) { - errors.add('$packageName (macOS)'); - } - } else { - printSkip('macOS is not supported by this plugin'); - } - } - - if (getBoolArg(kPlatformWeb)) { - print('\nBUILDING $packageName for web'); - if (isWebPlugin(package)) { - if (!await _buildExample(example, kPlatformWeb)) { - errors.add('$packageName (web)'); - } - } else { - printSkip('Web is not supported by this plugin'); - } - } - - if (getBoolArg(kPlatformWindows)) { - print('\nBUILDING $packageName for Windows'); - if (isWindowsPlugin(package)) { - if (!await _buildExample(example, kPlatformWindows)) { - errors.add('$packageName (Windows)'); - } - } else { - printSkip('Windows is not supported by this plugin'); + for (final _PlatformDetails platform in buildPlatforms) { + String buildPlatform = platform.label; + if (platform.label.toLowerCase() != platform.flutterBuildType) { + buildPlatform += ' (${platform.flutterBuildType})'; } - } - - if (getBoolArg(kPlatformIos)) { - print('\nBUILDING $packageName for iOS'); - if (isIosPlugin(package)) { - if (!await _buildExample( - example, - kPlatformIos, - extraBuildFlags: ['--no-codesign'], - )) { - errors.add('$packageName (iOS)'); - } - } else { - printSkip('iOS is not supported by this plugin'); - } - } - - if (getBoolArg(_platformFlagApk)) { - print('\nBUILDING APK for $packageName'); - if (isAndroidPlugin(package)) { - if (!await _buildExample(example, _platformFlagApk)) { - errors.add('$packageName (apk)'); - } - } else { - printSkip('Android is not supported by this plugin'); + print('\nBUILDING $packageName for $buildPlatform'); + if (!await _buildExample(example, platform.flutterBuildType, + extraBuildFlags: platform.extraBuildFlags)) { + errors.add('$packageName (${platform.label})'); } } } - return errors; + return errors.isEmpty + ? PackageResult.success() + : PackageResult.fail(errors); } Future _buildExample( @@ -166,3 +168,25 @@ class BuildExamplesCommand extends PackageLoopingCommand { return exitCode == 0; } } + +/// A collection of information related to a specific platform. +class _PlatformDetails { + const _PlatformDetails( + this.label, { + required this.pluginPlatform, + required this.flutterBuildType, + this.extraBuildFlags = const [], + }); + + /// The name to use in output. + final String label; + + /// The key in a pubspec's platform: entry. + final String pluginPlatform; + + /// The `flutter build` build type. + final String flutterBuildType; + + /// Any extra flags to pass to `flutter build`. + final List extraBuildFlags; +} diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart index 3b07baf5dc1e..b2be8f56d172 100644 --- a/script/tool/lib/src/common/core.dart +++ b/script/tool/lib/src/common/core.dart @@ -58,16 +58,6 @@ void printSuccess(String successMessage) { print(Colorize(successMessage)..green()); } -/// Prints `warningMessage` in yellow. -/// -/// Warnings are not surfaced in CI summaries, so this is only useful for -/// highlighting something when someone is already looking though the log -/// messages. DO NOT RELY on someone noticing a warning; instead, use it for -/// things that might be useful to someone debugging an unexpected result. -void printWarning(String warningMessage) { - print(Colorize(warningMessage)..yellow()); -} - /// Prints `errorMessage` in red. void printError(String errorMessage) { print(Colorize(errorMessage)..red()); diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index cfe99313068e..cd3c21db2137 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -11,6 +11,48 @@ import 'core.dart'; import 'plugin_command.dart'; import 'process_runner.dart'; +/// Possible outcomes of a command run for a package. +enum RunState { + /// The command succeeded for the package. + succeeded, + + /// The command was skipped for the package. + skipped, + + /// The command failed for the package. + failed, +} + +/// The result of a [runForPackage] call. +class PackageResult { + /// A successful result. + PackageResult.success() : this._(RunState.succeeded); + + /// A run that was skipped as explained in [reason]. + PackageResult.skip(String reason) + : this._(RunState.skipped, [reason]); + + /// A run that failed. + /// + /// If [errors] are provided, they will be listed in the summary, otherwise + /// the summary will simply show that the package failed. + PackageResult.fail([List errors = const []]) + : this._(RunState.failed, errors); + + const PackageResult._(this.state, [this.details = const []]); + + /// The state the package run completed with. + final RunState state; + + /// Information about the result: + /// - For `succeeded`, this is empty. + /// - For `skipped`, it contains a single entry describing why the run was + /// skipped. + /// - For `failed`, it contains zero or more specific error details to be + /// shown in the summary. + final List details; +} + /// An abstract base class for a command that iterates over a set of packages /// controlled by a standard set of flags, running some actions on each package, /// and collecting and reporting the success/failure of those actions. @@ -22,6 +64,15 @@ abstract class PackageLoopingCommand extends PluginCommand { GitDir? gitDir, }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + /// Packages that had at least one [logWarning] call. + final Set _packagesWithWarnings = {}; + + /// Number of warnings that happened outside of a [runForPackage] call. + int _otherWarningCount = 0; + + /// The package currently being run by [runForPackage]. + Directory? _currentPackage; + /// Called during [run] before any calls to [runForPackage]. This provides an /// opportunity to fail early if the command can't be run (e.g., because the /// arguments are invalid), and to set up any run-level state. @@ -33,7 +84,7 @@ abstract class PackageLoopingCommand extends PluginCommand { /// be included in the final error summary (e.g., a command that only has a /// single failure mode), or strings that should be listed for that package /// in the final summary. An empty list indicates success. - Future> runForPackage(Directory package); + Future runForPackage(Directory package); /// Called during [run] after all calls to [runForPackage]. This provides an /// opportunity to do any cleanup of run-level state. @@ -71,18 +122,19 @@ abstract class PackageLoopingCommand extends PluginCommand { // ---------------------------------------- - /// A convenience constant for [runForPackage] success that's more - /// self-documenting than the value. - static const List success = []; - - /// A convenience constant for [runForPackage] failure without additional - /// context that's more self-documenting than the value. - static const List failure = ['']; - - /// Prints a message using a standard format indicating that the package was - /// skipped, with an explanation of why. - void printSkip(String reason) { - print(Colorize('SKIPPING: $reason')..darkGray()); + /// Logs that a warning occurred, and prints `warningMessage` in yellow. + /// + /// Warnings are not surfaced in CI summaries, so this is only useful for + /// highlighting something when someone is already looking though the log + /// messages. DO NOT RELY on someone noticing a warning; instead, use it for + /// things that might be useful to someone debugging an unexpected result. + void logWarning(String warningMessage) { + print(Colorize(warningMessage)..yellow()); + if (_currentPackage != null) { + _packagesWithWarnings.add(_currentPackage!); + } else { + ++_otherWarningCount; + } } /// Returns the identifying name to use for [package]. @@ -110,41 +162,44 @@ abstract class PackageLoopingCommand extends PluginCommand { @override Future run() async { + _packagesWithWarnings.clear(); + _otherWarningCount = 0; + _currentPackage = null; + await initializeRun(); final List packages = includeSubpackages ? await getPackages().toList() : await getPlugins().toList(); - final Map> results = >{}; + final Map results = {}; for (final Directory package in packages) { + _currentPackage = package; _printPackageHeading(package); - results[package] = await runForPackage(package); + final PackageResult result = await runForPackage(package); + if (result.state == RunState.skipped) { + print(Colorize('${indentation}SKIPPING: ${result.details.first}') + ..darkGray()); + } + results[package] = result; } + _currentPackage = null; completeRun(); // If there were any errors reported, summarize them and exit. - if (results.values.any((List failures) => failures.isNotEmpty)) { - const String indentation = ' '; - printError(failureListHeader); - for (final Directory package in packages) { - final List errors = results[package]!; - if (errors.isNotEmpty) { - final String errorIndentation = indentation * 2; - String errorDetails = errors.join('\n$errorIndentation'); - if (errorDetails.isNotEmpty) { - errorDetails = ':\n$errorIndentation$errorDetails'; - } - printError( - '$indentation${getPackageDescription(package)}$errorDetails'); - } - } - printError(failureListFooter); + if (results.values + .any((PackageResult result) => result.state == RunState.failed)) { + _printFailureSummary(packages, results); throw ToolExit(exitCommandFoundErrors); } - printSuccess('\n\nNo issues found!'); + // Otherwise, print a summary of what ran for ease of auditing that all the + // expected tests ran. + _printRunSummary(packages, results); + + print('\n'); + printSuccess('No issues found!'); } /// Prints the status message indicating that the command is being run for @@ -167,4 +222,86 @@ abstract class PackageLoopingCommand extends PluginCommand { } print(Colorize(heading)..cyan()); } + + /// Prints a summary of packges run, packages skipped, and warnings. + void _printRunSummary( + List packages, Map results) { + final Set skippedPackages = results.entries + .where((MapEntry entry) => + entry.value.state == RunState.skipped) + .map((MapEntry entry) => entry.key) + .toSet(); + final int skipCount = skippedPackages.length; + // Split the warnings into those from packages that ran, and those that + // were skipped. + final Set _skippedPackagesWithWarnings = + _packagesWithWarnings.intersection(skippedPackages); + final int skippedWarningCount = _skippedPackagesWithWarnings.length; + final int runWarningCount = + _packagesWithWarnings.length - skippedWarningCount; + + final String runWarningSummary = + runWarningCount > 0 ? ' ($runWarningCount with warnings)' : ''; + final String skippedWarningSummary = + runWarningCount > 0 ? ' ($skippedWarningCount with warnings)' : ''; + print('------------------------------------------------------------'); + if (hasLongOutput) { + _printPerPackageRunOverview(packages, skipped: skippedPackages); + } + print( + 'Ran for ${packages.length - skipCount} package(s)$runWarningSummary'); + if (skipCount > 0) { + print('Skipped $skipCount package(s)$skippedWarningSummary'); + } + if (_otherWarningCount > 0) { + print('$_otherWarningCount warnings not associated with a package'); + } + } + + /// Prints a one-line-per-package overview of the run results for each + /// package. + void _printPerPackageRunOverview(List packages, + {required Set skipped}) { + print('Run overview:'); + for (final Directory package in packages) { + final bool hadWarning = _packagesWithWarnings.contains(package); + Colorize summary; + if (skipped.contains(package)) { + if (hadWarning) { + summary = Colorize('skipped (with warning)')..lightYellow(); + } else { + summary = Colorize('skipped')..darkGray(); + } + } else { + if (hadWarning) { + summary = Colorize('ran (with warning)')..yellow(); + } else { + summary = Colorize('ran')..green(); + } + } + print(' ${getPackageDescription(package)} - $summary'); + } + print(''); + } + + /// Prints a summary of all of the failures from [results]. + void _printFailureSummary( + List packages, Map results) { + const String indentation = ' '; + printError(failureListHeader); + for (final Directory package in packages) { + final PackageResult result = results[package]!; + if (result.state == RunState.failed) { + final String errorIndentation = indentation * 2; + String errorDetails = ''; + if (result.details.isNotEmpty) { + errorDetails = + ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; + } + printError( + '$indentation${getPackageDescription(package)}$errorDetails'); + } + } + printError(failureListFooter); + } } diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index a4aa7c12913d..dc9774d84621 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -118,14 +118,13 @@ class DriveExamplesCommand extends PackageLoopingCommand { } @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { if (package.basename.endsWith('_platform_interface') && !package.childDirectory('example').existsSync()) { // Platform interface packages generally aren't intended to have // examples, and don't need integration tests, so skip rather than fail. - printSkip( - 'Platform interfaces are not expected to have integratino tests.'); - return PackageLoopingCommand.success; + return PackageResult.skip( + 'Platform interfaces are not expected to have integration tests.'); } final List deviceFlags = []; @@ -139,9 +138,8 @@ class DriveExamplesCommand extends PackageLoopingCommand { } // If there is no supported target platform, skip the plugin. if (deviceFlags.isEmpty) { - printSkip( + return PackageResult.skip( '${getPackageDescription(package)} does not support any requested platform.'); - return PackageLoopingCommand.success; } int examplesFound = 0; @@ -195,7 +193,9 @@ class DriveExamplesCommand extends PackageLoopingCommand { printError('No driver tests were run ($examplesFound example(s) found).'); errors.add('No tests ran (use --exclude if this is intentional).'); } - return errors; + return errors.isEmpty + ? PackageResult.success() + : PackageResult.fail(errors); } Future> _getDevicesForPlatform(String platform) async { diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 9f4982b2783e..8d1ba995f179 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -100,19 +100,20 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { 'project', getStringArg('project'), ]); + print(''); if (exitCode == 0) { - print('\nFirebase project configured.'); + print('Firebase project configured.'); return; } else { - print( - '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.'); + logWarning( + 'Warning: gcloud config set returned a non-zero exit code. Continuing anyway.'); } } _firebaseProjectConfigured!.complete(null); } @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { if (!package .childDirectory('example') .childDirectory('android') @@ -120,29 +121,26 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { .childDirectory('src') .childDirectory('androidTest') .existsSync()) { - printSkip('No example with androidTest directory'); - return PackageLoopingCommand.success; + return PackageResult.skip('No example with androidTest directory'); } - final List errors = []; - final Directory exampleDirectory = package.childDirectory('example'); final Directory androidDirectory = exampleDirectory.childDirectory('android'); // Ensures that gradle wrapper exists if (!await _ensureGradleWrapperExists(androidDirectory)) { - errors.add('Unable to build example apk'); - return errors; + PackageResult.fail(['Unable to build example apk']); } await _configureFirebaseProject(); if (!await _runGradle(androidDirectory, 'app:assembleAndroidTest')) { - errors.add('Unable to assemble androidTest'); - return errors; + PackageResult.fail(['Unable to assemble androidTest']); } + final List errors = []; + // Used within the loop to ensure a unique GCS output location for each // test file's run. int resultsCounter = 0; @@ -186,7 +184,9 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { errors.add('$testName failed tests'); } } - return errors; + return errors.isEmpty + ? PackageResult.success() + : PackageResult.fail(errors); } /// Checks that 'gradlew' exists in [androidDirectory], and if not runs a diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart index 77b8aa70a6e4..1534fccff33c 100644 --- a/script/tool/lib/src/java_test_command.dart +++ b/script/tool/lib/src/java_test_command.dart @@ -28,7 +28,7 @@ class JavaTestCommand extends PackageLoopingCommand { 'command.'; @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { final Iterable examplesWithTests = getExamplesForPlugin(package) .where((Directory d) => isFlutterPackage(d) && @@ -44,6 +44,10 @@ class JavaTestCommand extends PackageLoopingCommand { .childDirectory('test') .existsSync())); + if (examplesWithTests.isEmpty) { + return PackageResult.skip('No Java unit tests.'); + } + final List errors = []; for (final Directory example in examplesWithTests) { final String exampleName = p.relative(example.path, from: package.path); @@ -66,6 +70,8 @@ class JavaTestCommand extends PackageLoopingCommand { errors.add('$exampleName tests failed.'); } } - return errors; + return errors.isEmpty + ? PackageResult.success() + : PackageResult.fail(errors); } } diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 2b4beeb92a1f..0bdb7c972cce 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -63,14 +63,22 @@ class LintPodspecsCommand extends PackageLoopingCommand { } @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { final List errors = []; - for (final File podspec in await _podspecsToLint(package)) { + + final List podspecs = await _podspecsToLint(package); + if (podspecs.isEmpty) { + return PackageResult.skip('No podspecs.'); + } + + for (final File podspec in podspecs) { if (!await _lintPodspec(podspec)) { errors.add(p.basename(podspec.path)); } } - return errors; + return errors.isEmpty + ? PackageResult.success() + : PackageResult.fail(errors); } Future> _podspecsToLint(Directory package) async { diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index d257638971db..7d39c7322b71 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -56,14 +56,14 @@ class PubspecCheckCommand extends PackageLoopingCommand { bool get includeSubpackages => true; @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { final File pubspec = package.childFile('pubspec.yaml'); final bool passesCheck = !pubspec.existsSync() || await _checkPubspec(pubspec, packageName: package.basename); if (!passesCheck) { - return PackageLoopingCommand.failure; + return PackageResult.fail(); } - return PackageLoopingCommand.success; + return PackageResult.success(); } Future _checkPubspec( diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 2584d70c5fc9..b26a7adc9b53 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -111,18 +111,15 @@ class VersionCheckCommand extends PackageLoopingCommand { Future initializeRun() async {} @override - Future> runForPackage(Directory package) async { - final List errors = []; - + Future runForPackage(Directory package) async { final Pubspec? pubspec = _tryParsePubspec(package); if (pubspec == null) { - errors.add('Invalid pubspec.yaml.'); - return errors; // No remaining checks make sense. + // No remaining checks make sense, so fail immediately. + return PackageResult.fail(['Invalid pubspec.yaml.']); } if (pubspec.publishTo == 'none') { - printSkip('${indentation}Found "publish_to: none".'); - return PackageLoopingCommand.success; + return PackageResult.skip('Found "publish_to: none".'); } final Version? currentPubspecVersion = pubspec.version; @@ -130,10 +127,12 @@ class VersionCheckCommand extends PackageLoopingCommand { printError('${indentation}No version found in pubspec.yaml. A package ' 'that intentionally has no version should be marked ' '"publish_to: none".'); - errors.add('No pubspec.yaml version.'); - return errors; // No remaining checks make sense. + // No remaining checks make sense, so fail immediately. + PackageResult.fail(['No pubspec.yaml version.']); } + final List errors = []; + if (!await _hasValidVersionChange(package, pubspec: pubspec)) { errors.add('Disallowed version change.'); } @@ -142,7 +141,9 @@ class VersionCheckCommand extends PackageLoopingCommand { errors.add('pubspec.yaml and CHANGELOG.md have different versions'); } - return errors; + return errors.isEmpty + ? PackageResult.success() + : PackageResult.fail(errors); } @override @@ -210,7 +211,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} if (previousVersion == Version.none) { print('${indentation}Unable to find previous version ' '${getBoolArg(_againstPubFlag) ? 'on pub server' : 'at git base'}.'); - printWarning( + logWarning( '${indentation}If this plugin is not new, something has gone wrong.'); return true; } diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 3f50feef6fe9..c7a454ab75a0 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -86,37 +86,58 @@ class XCTestCommand extends PackageLoopingCommand { } @override - Future> runForPackage(Directory package) async { - final List failures = []; - final bool testIos = getBoolArg(kPlatformIos); - final bool testMacos = getBoolArg(kPlatformMacos); - // Only provide the failing platform(s) in the summary if testing multiple - // platforms, otherwise it's just noise. - final bool provideErrorDetails = testIos && testMacos; + Future runForPackage(Directory package) async { + final bool testIos = getBoolArg(kPlatformIos) && + pluginSupportsPlatform(kPlatformIos, package, + requiredMode: PlatformSupport.inline); + final bool testMacos = getBoolArg(kPlatformMacos) && + pluginSupportsPlatform(kPlatformMacos, package, + requiredMode: PlatformSupport.inline); + + final bool multiplePlatformsRequested = + getBoolArg(kPlatformIos) && getBoolArg(kPlatformMacos); + if (!(testIos || testMacos)) { + String description; + if (multiplePlatformsRequested) { + description = 'Neither iOS nor macOS is'; + } else if (getBoolArg(kPlatformIos)) { + description = 'iOS is not'; + } else { + description = 'macOS is not'; + } + return PackageResult.skip( + '$description implemented by this plugin package.'); + } + if (multiplePlatformsRequested && (!testIos || !testMacos)) { + print('Only running for ${testIos ? 'iOS' : 'macOS'}\n'); + } + + final List failures = []; if (testIos && !await _testPlugin(package, 'iOS', extraXcrunFlags: _iosDestinationFlags)) { - failures.add(provideErrorDetails ? 'iOS' : ''); + failures.add('iOS'); } if (testMacos && !await _testPlugin(package, 'macOS')) { - failures.add(provideErrorDetails ? 'macOS' : ''); + failures.add('macOS'); } - return failures; + + // Only provide the failing platform in the failure details if testing + // multiple platforms, otherwise it's just noise. + return failures.isEmpty + ? PackageResult.success() + : PackageResult.fail( + multiplePlatformsRequested ? failures : []); } /// Runs all applicable tests for [plugin], printing status and returning - /// success if the tests passed (or did not exist). + /// success if the tests passed. Future _testPlugin( Directory plugin, String platform, { List extraXcrunFlags = const [], }) async { - if (!pluginSupportsPlatform(platform.toLowerCase(), plugin, - requiredMode: PlatformSupport.inline)) { - printSkip('$platform is not implemented by this plugin package.\n'); - return true; - } bool passing = true; for (final Directory example in getExamplesForPlugin(plugin)) { // Running tests and static analyzer. diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index c6febdc26fb8..218f448242b6 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -44,21 +44,16 @@ void main() { test('building for iOS when plugin is not set up for iOS results in no-op', () async { - final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + createFakePlugin('plugin', packagesDir, extraFiles: ['example/test']); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint(runner, ['build-examples', '--ios']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - contains('BUILDING $packageName for iOS'), + contains('Running for plugin'), contains('iOS is not supported by this plugin'), ]), ); @@ -113,23 +108,17 @@ void main() { test( 'building for Linux when plugin is not set up for Linux results in no-op', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ + createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/test', ]); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( runner, ['build-examples', '--linux']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - contains('BUILDING $packageName for Linux'), + contains('Running for plugin'), contains('Linux is not supported by this plugin'), ]), ); @@ -176,23 +165,17 @@ void main() { test('building for macos with no implementation results in no-op', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ + createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/test', ]); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( runner, ['build-examples', '--macos']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - contains('BUILDING $packageName for macOS'), + contains('Running for plugin'), contains('macOS is not supported by this plugin'), ]), ); @@ -239,24 +222,18 @@ void main() { }); test('building for web with no implementation results in no-op', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ + createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/test', ]); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint(runner, ['build-examples', '--web']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - contains('BUILDING $packageName for web'), - contains('Web is not supported by this plugin'), + contains('Running for plugin'), + contains('web is not supported by this plugin'), ]), ); @@ -304,29 +281,23 @@ void main() { test( 'building for Windows when plugin is not set up for Windows results in no-op', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ + createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/test', ]); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint( runner, ['build-examples', '--windows']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - contains('BUILDING $packageName for Windows'), + contains('Running for plugin'), contains('Windows is not supported by this plugin'), ]), ); - // Output should be empty since running build-examples --macos with no macos - // implementation is a no-op. + // Output should be empty since running build-examples --windows with no + // Windows implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -368,23 +339,17 @@ void main() { test( 'building for Android when plugin is not set up for Android results in no-op', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ + createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/test', ]); - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - final List output = await runCapturingPrint(runner, ['build-examples', '--apk']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - contains('\nBUILDING APK for $packageName'), + contains('Running for plugin'), contains('Android is not supported by this plugin'), ]), ); @@ -419,7 +384,7 @@ void main() { expect( output, containsAllInOrder([ - '\nBUILDING APK for $packageName', + '\nBUILDING $packageName for Android (apk)', ]), ); diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index 1012f764a62e..ee5aba5c5f55 100644 --- a/script/tool/test/common/package_looping_command_test.dart +++ b/script/tool/test/common/package_looping_command_test.dart @@ -19,14 +19,20 @@ import '../util.dart'; import 'plugin_command_test.mocks.dart'; // Constants for colorized output start and end. +const String _startErrorColor = '\x1B[31m'; const String _startHeadingColor = '\x1B[36m'; const String _startSkipColor = '\x1B[90m'; +const String _startSkipWithWarningColor = '\x1B[93m'; const String _startSuccessColor = '\x1B[32m'; -const String _startErrorColor = '\x1B[31m'; +const String _startWarningColor = '\x1B[33m'; const String _endColor = '\x1B[0m'; // The filename within a package containing errors to return from runForPackage. const String _errorFile = 'errors'; +// The filename within a package indicating that it should be skipped. +const String _skipFile = 'skip'; +// The filename within a package containing warnings to log during runForPackage. +const String _warningFile = 'warnings'; void main() { late FileSystem fileSystem; @@ -48,6 +54,8 @@ void main() { bool hasLongOutput = true, bool includeSubpackages = false, bool failsDuringInit = false, + bool warnsDuringInit = false, + bool warnsDuringCleanup = false, String? customFailureListHeader, String? customFailureListFooter, }) { @@ -68,6 +76,8 @@ void main() { hasLongOutput: hasLongOutput, includeSubpackages: includeSubpackages, failsDuringInit: failsDuringInit, + warnsDuringInit: warnsDuringInit, + warnsDuringCleanup: warnsDuringCleanup, customFailureListHeader: customFailureListHeader, customFailureListFooter: customFailureListFooter, gitDir: gitDir, @@ -216,7 +226,8 @@ void main() { expect( output, containsAllInOrder([ - '$_startSuccessColor\n\nNo issues found!$_endColor', + '\n', + '${_startSuccessColor}No issues found!$_endColor', ])); }); @@ -314,24 +325,153 @@ void main() { '${_startErrorColor}See above for full details.$_endColor', ])); }); - }); - group('utility', () { - test('printSkip has expected output', () async { + test('logs skips', () async { + createFakePackage('package_a', packagesDir); + final Directory skipPackage = createFakePackage('package_b', packagesDir); + skipPackage.childFile(_skipFile).writeAsStringSync('For a reason'); + final TestPackageLoopingCommand command = - TestPackageLoopingCommand(packagesDir); + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + '$_startSkipColor SKIPPING: For a reason$_endColor', + ])); + }); + + test('logs warnings', () async { + final Directory warnPackage = createFakePackage('package_a', packagesDir); + warnPackage + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startWarningColor}Warning 1$_endColor', + '${_startWarningColor}Warning 2$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + ])); + }); + + test('prints run summary on success', () async { + final Directory warnPackage1 = + createFakePackage('package_a', packagesDir); + warnPackage1 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_b', packagesDir); + final Directory skipPackage = createFakePackage('package_c', packagesDir); + skipPackage.childFile(_skipFile).writeAsStringSync('For a reason'); + final Directory skipAndWarnPackage = + createFakePackage('package_d', packagesDir); + skipAndWarnPackage.childFile(_warningFile).writeAsStringSync('Warning'); + skipAndWarnPackage.childFile(_skipFile).writeAsStringSync('See warning'); + final Directory warnPackage2 = + createFakePackage('package_e', packagesDir); + warnPackage2 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_f', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); - final List printBuffer = []; - Zone.current.fork(specification: ZoneSpecification( - print: (_, __, ___, String message) { - printBuffer.add(message); - }, - )).run(() => command.printSkip('For a reason')); + expect( + output, + containsAllInOrder([ + '------------------------------------------------------------', + 'Ran for 4 package(s) (2 with warnings)', + 'Skipped 2 package(s) (1 with warnings)', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + // The long-form summary should not be printed for short-form commands. + expect(output, isNot(contains('Run summary:'))); + expect(output, isNot(contains(contains('package a - ran')))); + }); - expect(printBuffer.first, - '${_startSkipColor}SKIPPING: For a reason$_endColor'); + test('prints long-form run summary for long-output commands', () async { + final Directory warnPackage1 = + createFakePackage('package_a', packagesDir); + warnPackage1 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_b', packagesDir); + final Directory skipPackage = createFakePackage('package_c', packagesDir); + skipPackage.childFile(_skipFile).writeAsStringSync('For a reason'); + final Directory skipAndWarnPackage = + createFakePackage('package_d', packagesDir); + skipAndWarnPackage.childFile(_warningFile).writeAsStringSync('Warning'); + skipAndWarnPackage.childFile(_skipFile).writeAsStringSync('See warning'); + final Directory warnPackage2 = + createFakePackage('package_e', packagesDir); + warnPackage2 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_f', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '------------------------------------------------------------', + 'Run overview:', + ' package_a - ${_startWarningColor}ran (with warning)$_endColor', + ' package_b - ${_startSuccessColor}ran$_endColor', + ' package_c - ${_startSkipColor}skipped$_endColor', + ' package_d - ${_startSkipWithWarningColor}skipped (with warning)$_endColor', + ' package_e - ${_startWarningColor}ran (with warning)$_endColor', + ' package_f - ${_startSuccessColor}ran$_endColor', + '', + 'Ran for 4 package(s) (2 with warnings)', + 'Skipped 2 package(s) (1 with warnings)', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); }); + test('handles warnings outside of runForPackage', () async { + createFakePackage('package_a', packagesDir); + + final TestPackageLoopingCommand command = createTestCommand( + hasLongOutput: false, + warnsDuringCleanup: true, + warnsDuringInit: true, + ); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startWarningColor}Warning during initializeRun$_endColor', + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startWarningColor}Warning during completeRun$_endColor', + '------------------------------------------------------------', + 'Ran for 1 package(s)', + '2 warnings not associated with a package', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + }); + }); + + group('utility', () { test('getPackageDescription prints packageDir-relative paths by default', () async { final TestPackageLoopingCommand command = @@ -380,6 +520,8 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { this.customFailureListHeader, this.customFailureListFooter, this.failsDuringInit = false, + this.warnsDuringInit = false, + this.warnsDuringCleanup = false, ProcessRunner processRunner = const ProcessRunner(), GitDir? gitDir, }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); @@ -390,6 +532,8 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { final String? customFailureListFooter; final bool failsDuringInit; + final bool warnsDuringInit; + final bool warnsDuringCleanup; @override bool hasLongOutput; @@ -413,20 +557,38 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { @override Future initializeRun() async { + if (warnsDuringInit) { + logWarning('Warning during initializeRun'); + } if (failsDuringInit) { throw ToolExit(2); } } @override - Future> runForPackage(Directory package) async { + Future runForPackage(Directory package) async { checkedPackages.add(package.path); + final File warningFile = package.childFile(_warningFile); + if (warningFile.existsSync()) { + final List warnings = warningFile.readAsLinesSync(); + warnings.forEach(logWarning); + } + final File skipFile = package.childFile(_skipFile); + if (skipFile.existsSync()) { + return PackageResult.skip(skipFile.readAsStringSync()); + } final File errorFile = package.childFile(_errorFile); if (errorFile.existsSync()) { - final List errors = errorFile.readAsLinesSync(); - return errors.isNotEmpty ? errors : PackageLoopingCommand.failure; + return PackageResult.fail(errorFile.readAsLinesSync()); + } + return PackageResult.success(); + } + + @override + Future completeRun() async { + if (warnsDuringInit) { + logWarning('Warning during completeRun'); } - return PackageLoopingCommand.success; } } diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index e441a0f68d9e..97a39c6b2bf6 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -740,7 +740,7 @@ void main() { containsAllInOrder([ contains('Running for aplugin_platform_interface'), contains( - 'SKIPPING: Platform interfaces are not expected to have integratino tests.'), + 'SKIPPING: Platform interfaces are not expected to have integration tests.'), contains('No issues found!'), ]), ); diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index e317ba924bd1..4285a0fee358 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -38,11 +38,8 @@ void main() { mockProcess.exitCodeCompleter.complete(1); processRunner.processToReturn = mockProcess; createFakePlugin('plugin', packagesDir, extraFiles: [ - 'lib/test/should_not_run_e2e.dart', - 'example/test_driver/plugin_e2e.dart', - 'example/test_driver/plugin_e2e_test.dart', + 'example/integration_test/foo_test.dart', 'example/android/gradlew', - 'example/should_not_run_e2e.dart', 'example/android/app/src/androidTest/MainActivityTest.java', ]); @@ -55,8 +52,10 @@ void main() { expect(commandError, isA()); expect( output, - contains( - '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.')); + containsAllInOrder([ + contains( + 'Warning: gcloud config set returned a non-zero exit code. Continuing anyway.'), + ])); }); test('runs integration tests', () async { diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index 894a5c3fce70..227327ab4e6c 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -151,5 +151,21 @@ void main() { ]), ); }); + + test('Skips when running no tests', () async { + createFakePlugin( + 'plugin1', + packagesDir, + ); + + final List output = + await runCapturingPrint(runner, ['java-test']); + + expect( + output, + containsAllInOrder( + [contains('SKIPPING: No Java unit tests.')]), + ); + }); }); } diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index a6a5502913a0..90a662d7500c 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -187,5 +187,18 @@ void main() { ], )); }); + + test('skips when there are no podspecs', () async { + createFakePlugin('plugin1', packagesDir); + + final List output = + await runCapturingPrint(runner, ['podspecs']); + + expect( + output, + containsAllInOrder( + [contains('SKIPPING: No podspecs.')], + )); + }); }); } diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index b12ad852cda7..9db4dac904ab 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -150,21 +150,16 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('running with correct destination, exclude 1 plugin', () async { - createFakePlugin('plugin1', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformIos: PlatformSupport.inline - }); - final Directory pluginDirectory2 = - createFakePlugin('plugin2', packagesDir, extraFiles: [ + test('running with correct destination', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/test', ], platformSupport: { kPlatformIos: PlatformSupport.inline }); - final Directory pluginExampleDirectory2 = - pluginDirectory2.childDirectory('example'); + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(0); @@ -176,16 +171,14 @@ void main() { '--ios', _kDestination, 'foo_destination', - '--exclude', - 'plugin1' ]); - expect(output, isNot(contains(contains('Running for plugin1')))); - expect(output, contains(contains('Running for plugin2'))); expect( output, - contains( - contains('Successfully ran iOS xctest for plugin2/example'))); + containsAllInOrder([ + contains('Running for plugin'), + contains('Successfully ran iOS xctest for plugin/example') + ])); expect( processRunner.recordedCalls, @@ -206,7 +199,7 @@ void main() { 'foo_destination', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', ], - pluginExampleDirectory2.path), + pluginExampleDirectory.path), ])); }); @@ -350,5 +343,211 @@ void main() { ])); }); }); + + group('combined', () { + test('runs both iOS and macOS when supported', () async { + final Directory pluginDirectory1 = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformIos: PlatformSupport.inline, + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAll([ + contains('Successfully ran iOS xctest for plugin/example'), + contains('Successfully ran macOS xctest for plugin/example'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + '-destination', + 'foo_destination', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'macos/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('runs only macOS for a macOS plugin', () async { + final Directory pluginDirectory1 = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('Only running for macOS'), + contains('Successfully ran macOS xctest for plugin/example'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'macos/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('runs only iOS for a iOS plugin', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('Only running for iOS'), + contains('Successfully ran iOS xctest for plugin/example') + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-configuration', + 'Debug', + '-scheme', + 'Runner', + '-destination', + 'foo_destination', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('skips when neither are supported', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ]); + + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(0); + processRunner.processToReturn = mockProcess; + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains( + 'SKIPPING: Neither iOS nor macOS is implemented by this plugin package.'), + ])); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + }); }); } From dc0f574d9510598143d507a5ff0a946b3a643adb Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 1 Jul 2021 18:05:54 -0700 Subject: [PATCH 101/364] [flutter_plugin_tools] Minor test cleanup (#4120) - Updates the remaining tests (other than one that still needs to be converted to the new base command, which will be fixed then) that aren't using runCapturingPrint to do so to reduce test log spam. - Simplifies and standardizes the matcher used for ToolExit in tests. --- script/tool/test/analyze_command_test.dart | 6 +-- .../tool/test/common/plugin_command_test.dart | 41 ++++++++++--------- .../create_all_plugins_app_command_test.dart | 6 +-- .../test/publish_plugin_command_test.dart | 16 ++++---- .../tool/test/pubspec_check_command_test.dart | 16 ++++---- script/tool/test/test_command_test.dart | 12 +++--- .../tool/test/version_check_command_test.dart | 8 ++-- script/tool/test/xctest_command_test.dart | 4 +- 8 files changed, 56 insertions(+), 53 deletions(-) diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index bdf9910f0b12..757adb622678 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -125,7 +125,7 @@ void main() { extraFiles: ['analysis_options.yaml']); await expectLater(() => runCapturingPrint(runner, ['analyze']), - throwsA(const TypeMatcher())); + throwsA(isA())); }); test('fails .analysis_options', () async { @@ -133,7 +133,7 @@ void main() { extraFiles: ['.analysis_options']); await expectLater(() => runCapturingPrint(runner, ['analyze']), - throwsA(const TypeMatcher())); + throwsA(isA())); }); test('takes an allow list', () async { @@ -168,7 +168,7 @@ void main() { await expectLater( () => runCapturingPrint( runner, ['analyze', '--custom-analysis', '']), - throwsA(const TypeMatcher())); + throwsA(isA())); }); }); } diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index deb8e4f56e2c..0c949da07dba 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -65,7 +65,7 @@ void main() { test('all plugins from file system', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run(['sample']); + await runCapturingPrint(runner, ['sample']); expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); @@ -74,7 +74,7 @@ void main() { final Directory plugin2 = createFakePlugin('plugin2', packagesDir); final Directory plugin3 = createFakePlugin('plugin3', thirdPartyPackagesDir); - await runner.run(['sample']); + await runCapturingPrint(runner, ['sample']); expect(plugins, unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); }); @@ -82,7 +82,7 @@ void main() { test('exclude plugins when plugins flag is specified', () async { createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run( + await runCapturingPrint(runner, ['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']); expect(plugins, unorderedEquals([plugin2.path])); }); @@ -90,14 +90,15 @@ void main() { test('exclude plugins when plugins flag isn\'t specified', () async { createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); - await runner.run(['sample', '--exclude=plugin1,plugin2']); + await runCapturingPrint( + runner, ['sample', '--exclude=plugin1,plugin2']); expect(plugins, unorderedEquals([])); }); test('exclude federated plugins when plugins flag is specified', () async { createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--plugins=federated/plugin1,plugin2', '--exclude=federated/plugin1' @@ -109,7 +110,7 @@ void main() { () async { createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--plugins=federated/plugin1,plugin2', '--exclude=federated' @@ -121,7 +122,7 @@ void main() { test('all plugins should be tested if there are no changes.', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -136,7 +137,7 @@ void main() { gitDiffResponse = 'AUTHORS'; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -152,7 +153,7 @@ packages/plugin1/CHANGELOG '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -168,7 +169,7 @@ packages/plugin1/CHANGELOG '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -185,7 +186,7 @@ packages/plugin1/CHANGELOG '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -202,7 +203,7 @@ packages/plugin1/CHANGELOG '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -219,7 +220,7 @@ packages/plugin1/CHANGELOG '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -236,7 +237,7 @@ packages/plugin1/CHANGELOG '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -249,7 +250,7 @@ packages/plugin1/CHANGELOG gitDiffResponse = 'packages/plugin1/plugin1.dart'; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -266,7 +267,7 @@ packages/plugin1/ios/plugin1.m '''; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -284,7 +285,7 @@ packages/plugin2/ios/plugin2.m final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -305,7 +306,7 @@ packages/plugin1/plugin1_web/plugin1_web.dart createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' @@ -325,7 +326,7 @@ packages/plugin3/plugin3.dart createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--plugins=plugin1,plugin2', '--base-sha=master', @@ -345,7 +346,7 @@ packages/plugin3/plugin3.dart createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); - await runner.run([ + await runCapturingPrint(runner, [ 'sample', '--exclude=plugin2,plugin3', '--base-sha=master', diff --git a/script/tool/test/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index 5bde5e0dc004..073024a17bb3 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -45,7 +45,7 @@ void main() { createFakePlugin('pluginb', packagesDir); createFakePlugin('pluginc', packagesDir); - await runner.run(['all-plugins-app']); + await runCapturingPrint(runner, ['all-plugins-app']); final List pubspec = appDir.childFile('pubspec.yaml').readAsLinesSync(); @@ -63,7 +63,7 @@ void main() { createFakePlugin('pluginb', packagesDir); createFakePlugin('pluginc', packagesDir); - await runner.run(['all-plugins-app']); + await runCapturingPrint(runner, ['all-plugins-app']); final List pubspec = appDir.childFile('pubspec.yaml').readAsLinesSync(); @@ -80,7 +80,7 @@ void main() { test('pubspec is compatible with null-safe app code', () async { createFakePlugin('plugina', packagesDir); - await runner.run(['all-plugins-app']); + await runCapturingPrint(runner, ['all-plugins-app']); final String pubspec = appDir.childFile('pubspec.yaml').readAsStringSync(); diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index f060cd2fbfd8..497579b02f89 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -77,7 +77,7 @@ void main() { group('Initial validation', () { test('requires a package flag', () async { await expectLater(() => commandRunner.run(['publish-plugin']), - throwsA(const TypeMatcher())); + throwsA(isA())); expect( printedMessages.last, contains('Must specify a package to publish.')); }); @@ -90,7 +90,7 @@ void main() { 'iamerror', '--no-push-tags' ]), - throwsA(const TypeMatcher())); + throwsA(isA())); expect(printedMessages.last, contains('iamerror does not exist')); }); @@ -105,7 +105,7 @@ void main() { testPluginName, '--no-push-tags' ]), - throwsA(const TypeMatcher())); + throwsA(isA())); expect( printedMessages, @@ -119,7 +119,7 @@ void main() { await expectLater( () => commandRunner .run(['publish-plugin', '--package', testPluginName]), - throwsA(const TypeMatcher())); + throwsA(isA())); expect(processRunner.results.last.stderr, contains('No such remote')); }); @@ -248,7 +248,7 @@ void main() { '--no-push-tags', '--no-tag-release', ]), - throwsA(const TypeMatcher())); + throwsA(isA())); expect(printedMessages, contains('Publish foo failed.')); }); @@ -301,7 +301,7 @@ void main() { testPluginName, '--no-push-tags', ]), - throwsA(const TypeMatcher())); + throwsA(isA())); expect(printedMessages, contains('Publish foo failed.')); final String? tag = (await gitDir.runCommand( @@ -327,7 +327,7 @@ void main() { '--package', testPluginName, ]), - throwsA(const TypeMatcher())); + throwsA(isA())); expect(printedMessages, contains('Tag push canceled.')); }); @@ -958,7 +958,7 @@ void main() { await expectLater( () => commandRunner.run( ['publish-plugin', '--all-changed', '--base-sha=HEAD~']), - throwsA(const TypeMatcher())); + throwsA(isA())); expect(processRunner.pushTagsArgs, isEmpty); }); diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 38182a4d183f..9e633e21b4ab 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -176,7 +176,7 @@ ${devDependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -196,7 +196,7 @@ ${devDependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -216,7 +216,7 @@ ${devDependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -236,7 +236,7 @@ ${devDependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -256,7 +256,7 @@ ${environmentSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -276,7 +276,7 @@ ${devDependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -296,7 +296,7 @@ ${dependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -316,7 +316,7 @@ ${dependenciesSection()} await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); }); diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index fdccae3d5520..861a485f9281 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -36,7 +36,7 @@ void main() { final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, extraFiles: ['test/empty_test.dart']); - await runner.run(['test']); + await runCapturingPrint(runner, ['test']); expect( processRunner.recordedCalls, @@ -54,7 +54,7 @@ void main() { final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, extraFiles: ['test/empty_test.dart']); - await runner.run(['test']); + await runCapturingPrint(runner, ['test']); expect( processRunner.recordedCalls, @@ -71,7 +71,8 @@ void main() { final Directory packageDir = createFakePackage('b', packagesDir, extraFiles: ['test/empty_test.dart']); - await runner.run(['test', '--enable-experiment=exp1']); + await runCapturingPrint( + runner, ['test', '--enable-experiment=exp1']); expect( processRunner.recordedCalls, @@ -99,7 +100,7 @@ void main() { }, ); - await runner.run(['test']); + await runCapturingPrint(runner, ['test']); expect( processRunner.recordedCalls, @@ -118,7 +119,8 @@ void main() { final Directory packageDir = createFakePackage('b', packagesDir, extraFiles: ['test/empty_test.dart']); - await runner.run(['test', '--enable-experiment=exp1']); + await runCapturingPrint( + runner, ['test', '--enable-experiment=exp1']); expect( processRunner.recordedCalls, diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 4d884692046d..6fbed9c691b3 100644 --- a/script/tool/test/version_check_command_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -120,7 +120,7 @@ void main() { await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); expect(gitDirCommands.length, equals(1)); expect( @@ -188,7 +188,7 @@ void main() { await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -202,7 +202,7 @@ void main() { await expectLater( result, - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -244,7 +244,7 @@ void main() { runner, ['version-check', '--base-sha=master']); await expectLater( output, - throwsA(const TypeMatcher()), + throwsA(isA()), ); expect(gitDirCommands.length, equals(1)); expect( diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 9db4dac904ab..61d303120275 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -106,7 +106,7 @@ void main() { test('Fails if no platforms are provided', () async { expect( - () => runner.run(['xctest']), + () => runCapturingPrint(runner, ['xctest']), throwsA(isA()), ); }); @@ -227,7 +227,7 @@ void main() { // will get this result and they should still be able to parse them correctly. processRunner.resultStdout = jsonEncode(schemeCommandResult..addAll(_kDeviceListMap)); - await runner.run(['xctest', '--ios']); + await runCapturingPrint(runner, ['xctest', '--ios']); expect( processRunner.recordedCalls, From 9bd9ca1d7423f1e1a1f063ae87deff41c2b191b1 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 2 Jul 2021 07:50:24 -0700 Subject: [PATCH 102/364] [flutter_plugin_tools] Remove most exitOnError:true usage (#4130) The intent of package-looping commands is that we always want to run them for all the targeted packages, accumulating failures, and then report them all at the end, so we don't end up with the problem of only finding errors one at a time. However, some of them were using `exitOnError: true` for the underlying commands, causing the first error to be fatal to the test. This PR: - Removes all use of `exitOnError: true` from the package-looping commands. (It's still used in `format`, but fixing that is a larger issue, and less important due to the way `format` is currently structured.) - Fixes the mock process runner used in our tests to correctly simulate `exitOrError: true`; the fact that it didn't was hiding this problem in test (e.g., `drive-examples` had a test that asserted that all failures were summarized correctly, which passed because in tests it was behaving as if `exitOnError` were false) - Adjusts the mock process runner to allow setting a list of mock result for a specific executable instead of one result for all calls to anything, in order to fix some tests that were broken by the two changes above and unfixable without this (e.g., a test of a command that had been calling one executable with `exitOnError: true` then another with ``exitOnError: false`, where the test was asserting things about the second call failing, which only worked because the first call's failure wasn't actually checked). To limit the scope of the PR, the old method of setting a single result for all calls is still supported for now as a fallback. - Fixes the fact that the mock `run` and `runAndStream` had opposite default exit code behavior when no mock process was set (since that caused me a lot of confusion while fixing the above until I figured it out). --- script/tool/CHANGELOG.md | 2 + script/tool/lib/src/analyze_command.dart | 17 ++++++-- .../tool/lib/src/drive_examples_command.dart | 5 +-- .../lib/src/firebase_test_lab_command.dart | 9 +++- .../tool/lib/src/lint_podspecs_command.dart | 8 +++- .../tool/lib/src/publish_plugin_command.dart | 7 ++-- script/tool/lib/src/xctest_command.dart | 2 +- .../test/drive_examples_command_test.dart | 32 ++++++++++++++- .../test/firebase_test_lab_command_test.dart | 31 +++++++++++++- .../tool/test/lint_podspecs_command_test.dart | 34 ++++++++++++++- script/tool/test/util.dart | 41 +++++++++++++++---- 11 files changed, 160 insertions(+), 28 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index d64655f5b868..db9ecf493020 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -5,6 +5,8 @@ files, only `integration_test/*_test.dart`. - Add a summary to the end of successful command runs for commands using the new output format. +- Fixed some cases where a failure in a command for a single package would + immediately abort the test. ## 0.3.0 diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 29e78f3ea4ff..adaeb1e616e7 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -12,6 +12,7 @@ import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; const int _exitBadCustomAnalysisFile = 2; +const int _exitPackagesGetFailed = 3; /// A command to run Dart analysis on packages. class AnalyzeCommand extends PackageLoopingCommand { @@ -75,7 +76,7 @@ class AnalyzeCommand extends PackageLoopingCommand { /// Ensures that the dependent packages have been fetched for all packages /// (including their sub-packages) that will be analyzed. - Future _runPackagesGetOnTargetPackages() async { + Future _runPackagesGetOnTargetPackages() async { final List packageDirectories = await getPackages().toList(); final Set packagePaths = packageDirectories.map((Directory dir) => dir.path).toSet(); @@ -87,9 +88,14 @@ class AnalyzeCommand extends PackageLoopingCommand { packagePaths.contains(directory.parent.path); }); for (final Directory package in packageDirectories) { - await processRunner.runAndStream('flutter', ['packages', 'get'], - workingDir: package, exitOnError: true); + final int exitCode = await processRunner.runAndStream( + 'flutter', ['packages', 'get'], + workingDir: package); + if (exitCode != 0) { + return false; + } } + return true; } @override @@ -98,7 +104,10 @@ class AnalyzeCommand extends PackageLoopingCommand { _validateAnalysisOptions(); print('Fetching dependencies...'); - await _runPackagesGetOnTargetPackages(); + if (!await _runPackagesGetOnTargetPackages()) { + printError('Unabled to get dependencies.'); + throw ToolExit(_exitPackagesGetFailed); + } // Use the Dart SDK override if one was passed in. final String? dartSdk = argResults![_analysisSdk] as String?; diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index dc9774d84621..1e19535f4a25 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -203,7 +203,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { final ProcessResult result = await processRunner.run( flutterCommand, ['devices', '--machine'], - stdoutEncoding: utf8, exitOnError: true); + stdoutEncoding: utf8); if (result.exitCode != 0) { return deviceIds; } @@ -295,8 +295,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { '--target', p.relative(target.path, from: example.path), ], - workingDir: example, - exitOnError: true); + workingDir: example); if (exitCode != 0) { failures.add(target); } diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 8d1ba995f179..3d68c85fbd13 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -13,6 +13,8 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +const int _exitGcloudAuthFailed = 2; + /// A command to run tests via Firebase test lab. class FirebaseTestLabCommand extends PackageLoopingCommand { /// Creates an instance of the test runner command. @@ -84,16 +86,19 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { if (serviceKey.isEmpty) { print('No --service-key provided; skipping gcloud authorization'); } else { - await processRunner.run( + final io.ProcessResult result = await processRunner.run( 'gcloud', [ 'auth', 'activate-service-account', '--key-file=$serviceKey', ], - exitOnError: true, logOnError: true, ); + if (result.exitCode != 0) { + printError('Unable to activate gcloud account.'); + throw ToolExit(_exitGcloudAuthFailed); + } final int exitCode = await processRunner.runAndStream('gcloud', [ 'config', 'set', diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 0bdb7c972cce..82cce0bd13e6 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -14,6 +14,7 @@ import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; const int _exitUnsupportedPlatform = 2; +const int _exitPodNotInstalled = 3; /// Lint the CocoaPod podspecs and run unit tests. /// @@ -53,13 +54,16 @@ class LintPodspecsCommand extends PackageLoopingCommand { throw ToolExit(_exitUnsupportedPlatform); } - await processRunner.run( + final ProcessResult result = await processRunner.run( 'which', ['pod'], workingDir: packagesDir, - exitOnError: true, logOnError: true, ); + if (result.exitCode != 0) { + printError('Unable to find "pod". Make sure it is in your path.'); + throw ToolExit(_exitPodNotInstalled); + } } @override diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 740178829cac..6de53ba2690a 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -355,7 +355,6 @@ Safe to ignore if the package is deleted in this commit. 'git', ['tag', tag], workingDir: packageDir, - exitOnError: false, logOnError: true, ); if (result.exitCode != 0) { @@ -402,7 +401,6 @@ Safe to ignore if the package is deleted in this commit. ['status', '--porcelain', '--ignored', packageDir.absolute.path], workingDir: packageDir, logOnError: true, - exitOnError: false, ); if (statusResult.exitCode != 0) { return false; @@ -423,9 +421,11 @@ Safe to ignore if the package is deleted in this commit. 'git', ['remote', 'get-url', remote], workingDir: packagesDir, - exitOnError: true, logOnError: true, ); + if (getRemoteUrlResult.exitCode != 0) { + return null; + } return getRemoteUrlResult.stdout as String?; } @@ -498,7 +498,6 @@ Safe to ignore if the package is deleted in this commit. 'git', ['push', remote.name, tag], workingDir: packagesDir, - exitOnError: false, logOnError: true, ); if (result.exitCode != 0) { diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index c7a454ab75a0..cd3b674f8d3a 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -184,7 +184,7 @@ class XCTestCommand extends PackageLoopingCommand { '$_kXCRunCommand ${xctestArgs.join(' ')}'; print(completeTestCommand); return processRunner.runAndStream(_kXCRunCommand, xctestArgs, - workingDir: example, exitOnError: false); + workingDir: example); } Future _findAvailableIphoneSimulator() async { diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 97a39c6b2bf6..eeac96e56e60 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as io; + import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -53,7 +55,9 @@ void main() { final MockProcess mockDevicesProcess = MockProcess(); mockDevicesProcess.exitCodeCompleter.complete(0); mockDevicesProcess.stdoutController.close(); // ignore: unawaited_futures - processRunner.processToReturn = mockDevicesProcess; + processRunner.mockProcessesForExecutable['flutter'] = [ + mockDevicesProcess + ]; processRunner.resultStdout = output; } @@ -110,7 +114,31 @@ void main() { ); }); - test('fails if Android if no Android devices are present', () async { + test('fails for iOS if getting devices fails', () async { + setMockFlutterDevicesOutput(hasIosDevice: false); + + // Simulate failure from `flutter devices`. + final MockProcess mockProcess = MockProcess(); + mockProcess.exitCodeCompleter.complete(1); + processRunner.processToReturn = mockProcess; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['drive-examples', '--ios'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No iOS devices'), + ]), + ); + }); + + test('fails for Android if no Android devices are present', () async { + setMockFlutterDevicesOutput(hasAndroidDevice: false); Error? commandError; final List output = await runCapturingPrint( runner, ['drive-examples', '--android'], diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index 4285a0fee358..711c383f2d5d 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -33,10 +33,12 @@ void main() { runner.addCommand(command); }); - test('retries gcloud set', () async { + test('fails if gcloud auth fails', () async { final MockProcess mockProcess = MockProcess(); mockProcess.exitCodeCompleter.complete(1); - processRunner.processToReturn = mockProcess; + processRunner.mockProcessesForExecutable['gcloud'] = [ + mockProcess + ]; createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', 'example/android/gradlew', @@ -50,6 +52,31 @@ void main() { }); expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to activate gcloud account.'), + ])); + }); + + test('retries gcloud set', () async { + final MockProcess mockAuthProcess = MockProcess(); + mockAuthProcess.exitCodeCompleter.complete(0); + final MockProcess mockConfigProcess = MockProcess(); + mockConfigProcess.exitCodeCompleter.complete(1); + processRunner.mockProcessesForExecutable['gcloud'] = [ + mockAuthProcess, + mockConfigProcess, + ]; + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final List output = + await runCapturingPrint(runner, ['firebase-test-lab']); + expect( output, containsAllInOrder([ diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 90a662d7500c..c61d6a9d9281 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as io; + import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -160,6 +162,34 @@ void main() { expect(output, contains('Linting plugin1.podspec')); }); + test('fails if pod is missing', () async { + createFakePlugin('plugin1', packagesDir, + extraFiles: ['plugin1.podspec']); + + // Simulate failure from `which pod`. + final MockProcess mockWhichProcess = MockProcess(); + mockWhichProcess.exitCodeCompleter.complete(1); + processRunner.mockProcessesForExecutable['which'] = [ + mockWhichProcess + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['podspecs'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder( + [ + contains('Unable to find "pod". Make sure it is in your path.'), + ], + )); + }); + test('fails if linting fails', () async { createFakePlugin('plugin1', packagesDir, extraFiles: ['plugin1.podspec']); @@ -167,7 +197,9 @@ void main() { // Simulate failure from `pod`. final MockProcess mockDriveProcess = MockProcess(); mockDriveProcess.exitCodeCompleter.complete(1); - processRunner.processToReturn = mockDriveProcess; + processRunner.mockProcessesForExecutable['pod'] = [ + mockDriveProcess + ]; Error? commandError; final List output = await runCapturingPrint( diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index e71a26fa4ebb..b65b1fcaa84a 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -252,15 +252,22 @@ Future> runCapturingPrint( /// A mock [ProcessRunner] which records process calls. class RecordingProcessRunner extends ProcessRunner { - io.Process? processToReturn; final List recordedCalls = []; + /// Maps an executable to a list of processes that should be used for each + /// successive call to it via [run], [runAndStream], or [start]. + final Map> mockProcessesForExecutable = + >{}; + /// Populate for [io.ProcessResult] to use a String [stdout] instead of a [List] of [int]. String? resultStdout; /// Populate for [io.ProcessResult] to use a String [stderr] instead of a [List] of [int]. String? resultStderr; + // Deprecated--do not add new uses. Use mockProcessesForExecutable instead. + io.Process? processToReturn; + @override Future runAndStream( String executable, @@ -269,11 +276,17 @@ class RecordingProcessRunner extends ProcessRunner { bool exitOnError = false, }) async { recordedCalls.add(ProcessCall(executable, args, workingDir?.path)); - return Future.value( - processToReturn == null ? 0 : await processToReturn!.exitCode); + final io.Process? processToReturn = _getProcessToReturn(executable); + final int exitCode = + processToReturn == null ? 0 : await processToReturn.exitCode; + if (exitOnError && (exitCode != 0)) { + throw io.ProcessException(executable, args); + } + return Future.value(exitCode); } - /// Returns [io.ProcessResult] created from [processToReturn], [resultStdout], and [resultStderr]. + /// Returns [io.ProcessResult] created from [mockProcessesForExecutable], + /// [resultStdout], and [resultStderr]. @override Future run( String executable, @@ -286,12 +299,16 @@ class RecordingProcessRunner extends ProcessRunner { }) async { recordedCalls.add(ProcessCall(executable, args, workingDir?.path)); - final io.Process? process = processToReturn; + final io.Process? process = _getProcessToReturn(executable); final io.ProcessResult result = process == null - ? io.ProcessResult(1, 1, '', '') + ? io.ProcessResult(1, 0, '', '') : io.ProcessResult(process.pid, await process.exitCode, resultStdout ?? process.stdout, resultStderr ?? process.stderr); + if (exitOnError && (result.exitCode != 0)) { + throw io.ProcessException(executable, args); + } + return Future.value(result); } @@ -299,7 +316,17 @@ class RecordingProcessRunner extends ProcessRunner { Future start(String executable, List args, {Directory? workingDirectory}) async { recordedCalls.add(ProcessCall(executable, args, workingDirectory?.path)); - return Future.value(processToReturn); + return Future.value(_getProcessToReturn(executable)); + } + + io.Process? _getProcessToReturn(String executable) { + io.Process? process; + final List? processes = mockProcessesForExecutable[executable]; + if (processes != null && processes.isNotEmpty) { + process = mockProcessesForExecutable[executable]!.removeAt(0); + } + // Fall back to `processToReturn` for backwards compatibility. + return process ?? processToReturn; } } From 24febdf3aefdf469f7e30168a57f227682373146 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 2 Jul 2021 09:30:49 -0700 Subject: [PATCH 103/364] [flutter_plugin_tools] Migrate publish-check to the new base command (#4119) To support this command's --machine flag, which moves all normal output into a field in a JSON struct, adds a way of capturing output and providing it to the command subclass on completion. Part of flutter/flutter#83413 --- .../src/common/package_looping_command.dart | 85 +++++++--- .../tool/lib/src/publish_check_command.dart | 160 +++++++----------- .../common/package_looping_command_test.dart | 37 ++++ .../tool/test/publish_check_command_test.dart | 63 ++++--- 4 files changed, 209 insertions(+), 136 deletions(-) diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index cd3c21db2137..de1e3b861f59 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -2,6 +2,8 @@ // 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:colorize/colorize.dart'; import 'package:file/file.dart'; import 'package:git/git.dart'; @@ -90,6 +92,10 @@ abstract class PackageLoopingCommand extends PluginCommand { /// opportunity to do any cleanup of run-level state. Future completeRun() async {} + /// If [captureOutput], this is called just before exiting with all captured + /// [output]. + Future handleCapturedOutput(List output) async {} + /// Whether or not the output (if any) of [runForPackage] is long, or short. /// /// This changes the logging that happens at the start of each package's @@ -120,6 +126,14 @@ abstract class PackageLoopingCommand extends PluginCommand { /// context. String get failureListFooter => 'See above for full details.'; + /// If true, all printing (including the summary) will be redirected to a + /// buffer, and provided in a call to [handleCapturedOutput] at the end of + /// the run. + /// + /// Capturing output will disable any colorizing of output from this base + /// class. + bool get captureOutput => false; + // ---------------------------------------- /// Logs that a warning occurred, and prints `warningMessage` in yellow. @@ -162,6 +176,26 @@ abstract class PackageLoopingCommand extends PluginCommand { @override Future run() async { + bool succeeded; + if (captureOutput) { + final List output = []; + final ZoneSpecification logSwitchSpecification = ZoneSpecification( + print: (Zone self, ZoneDelegate parent, Zone zone, String message) { + output.add(message); + }); + succeeded = await runZoned>(_runInternal, + zoneSpecification: logSwitchSpecification); + await handleCapturedOutput(output); + } else { + succeeded = await _runInternal(); + } + + if (!succeeded) { + throw ToolExit(exitCommandFoundErrors); + } + } + + Future _runInternal() async { _packagesWithWarnings.clear(); _otherWarningCount = 0; _currentPackage = null; @@ -178,8 +212,9 @@ abstract class PackageLoopingCommand extends PluginCommand { _printPackageHeading(package); final PackageResult result = await runForPackage(package); if (result.state == RunState.skipped) { - print(Colorize('${indentation}SKIPPING: ${result.details.first}') - ..darkGray()); + final String message = + '${indentation}SKIPPING: ${result.details.first}'; + captureOutput ? print(message) : print(Colorize(message)..darkGray()); } results[package] = result; } @@ -187,11 +222,12 @@ abstract class PackageLoopingCommand extends PluginCommand { completeRun(); + print('\n'); // If there were any errors reported, summarize them and exit. if (results.values .any((PackageResult result) => result.state == RunState.failed)) { _printFailureSummary(packages, results); - throw ToolExit(exitCommandFoundErrors); + return false; } // Otherwise, print a summary of what ran for ease of auditing that all the @@ -199,7 +235,16 @@ abstract class PackageLoopingCommand extends PluginCommand { _printRunSummary(packages, results); print('\n'); - printSuccess('No issues found!'); + _printSuccess('No issues found!'); + return true; + } + + void _printSuccess(String message) { + captureOutput ? print(message) : printSuccess(message); + } + + void _printError(String message) { + captureOutput ? print(message) : printError(message); } /// Prints the status message indicating that the command is being run for @@ -220,7 +265,7 @@ abstract class PackageLoopingCommand extends PluginCommand { } else { heading = '$heading...'; } - print(Colorize(heading)..cyan()); + captureOutput ? print(heading) : print(Colorize(heading)..cyan()); } /// Prints a summary of packges run, packages skipped, and warnings. @@ -265,19 +310,21 @@ abstract class PackageLoopingCommand extends PluginCommand { print('Run overview:'); for (final Directory package in packages) { final bool hadWarning = _packagesWithWarnings.contains(package); - Colorize summary; + Styles style; + String summary; if (skipped.contains(package)) { - if (hadWarning) { - summary = Colorize('skipped (with warning)')..lightYellow(); - } else { - summary = Colorize('skipped')..darkGray(); - } + summary = 'skipped'; + style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY; } else { - if (hadWarning) { - summary = Colorize('ran (with warning)')..yellow(); - } else { - summary = Colorize('ran')..green(); - } + summary = 'ran'; + style = hadWarning ? Styles.YELLOW : Styles.GREEN; + } + if (hadWarning) { + summary += ' (with warning)'; + } + + if (!captureOutput) { + summary = (Colorize(summary)..apply(style)).toString(); } print(' ${getPackageDescription(package)} - $summary'); } @@ -288,7 +335,7 @@ abstract class PackageLoopingCommand extends PluginCommand { void _printFailureSummary( List packages, Map results) { const String indentation = ' '; - printError(failureListHeader); + _printError(failureListHeader); for (final Directory package in packages) { final PackageResult result = results[package]!; if (result.state == RunState.failed) { @@ -298,10 +345,10 @@ abstract class PackageLoopingCommand extends PluginCommand { errorDetails = ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; } - printError( + _printError( '$indentation${getPackageDescription(package)}$errorDetails'); } } - printError(failureListFooter); + _printError(failureListFooter); } } diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index 82a76609e98b..ccafabfddd1d 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -6,19 +6,18 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; -import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; import 'package:http/http.dart' as http; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; import 'common/pub_version_finder.dart'; /// A command to check that packages are publishable via 'dart publish'. -class PublishCheckCommand extends PluginCommand { +class PublishCheckCommand extends PackageLoopingCommand { /// Creates an instance of the publish command. PublishCheckCommand( Directory packagesDir, { @@ -52,12 +51,6 @@ class PublishCheckCommand extends PluginCommand { static const String _statusKey = 'status'; static const String _humanMessageKey = 'humanMessage'; - final List _validStatus = [ - _statusNeedsPublish, - _statusMessageNoPublish, - _statusMessageError - ]; - @override final String name = 'publish-check'; @@ -67,68 +60,55 @@ class PublishCheckCommand extends PluginCommand { final PubVersionFinder _pubVersionFinder; - // The output JSON when the _machineFlag is on. - final Map _machineOutput = {}; - - final List _humanMessages = []; + /// The overall result of the run for machine-readable output. This is the + /// highest value that occurs during the run. + _PublishCheckResult _overallResult = _PublishCheckResult.nothingToPublish; @override - Future run() async { - final ZoneSpecification logSwitchSpecification = ZoneSpecification( - print: (Zone self, ZoneDelegate parent, Zone zone, String message) { - final bool logMachineMessage = getBoolArg(_machineFlag); - if (logMachineMessage && message != _prettyJson(_machineOutput)) { - _humanMessages.add(message); - } else { - parent.print(zone, message); - } - }); + bool get captureOutput => getBoolArg(_machineFlag); - await runZoned(_runCommand, zoneSpecification: logSwitchSpecification); + @override + Future initializeRun() async { + _overallResult = _PublishCheckResult.nothingToPublish; } - Future _runCommand() async { - final List failedPackages = []; - - String status = _statusMessageNoPublish; - await for (final Directory plugin in getPlugins()) { - final _PublishCheckResult result = await _passesPublishCheck(plugin); - switch (result) { - case _PublishCheckResult._notPublished: - if (failedPackages.isEmpty) { - status = _statusNeedsPublish; - } - break; - case _PublishCheckResult._published: - break; - case _PublishCheckResult._error: - failedPackages.add(plugin); - status = _statusMessageError; - break; - } + @override + Future runForPackage(Directory package) async { + final _PublishCheckResult? result = await _passesPublishCheck(package); + if (result == null) { + return PackageResult.skip('Package is marked as unpublishable.'); } + if (result.index > _overallResult.index) { + _overallResult = result; + } + return result == _PublishCheckResult.error + ? PackageResult.fail() + : PackageResult.success(); + } + + @override + Future completeRun() async { _pubVersionFinder.httpClient.close(); + } - if (failedPackages.isNotEmpty) { - final String error = - 'The following ${failedPackages.length} package(s) failed the ' - 'publishing check:'; - final String joinedFailedPackages = failedPackages.join('\n'); - _printImportantStatusMessage('$error\n$joinedFailedPackages', - isError: true); - } else { - _printImportantStatusMessage('All packages passed publish check!', - isError: false); - } + @override + Future handleCapturedOutput(List output) async { + final Map machineOutput = { + _statusKey: _statusStringForResult(_overallResult), + _humanMessageKey: output, + }; - if (getBoolArg(_machineFlag)) { - _setStatus(status); - _machineOutput[_humanMessageKey] = _humanMessages; - print(_prettyJson(_machineOutput)); - } + print(const JsonEncoder.withIndent(' ').convert(machineOutput)); + } - if (failedPackages.isNotEmpty) { - throw ToolExit(1); + String _statusStringForResult(_PublishCheckResult result) { + switch (result) { + case _PublishCheckResult.nothingToPublish: + return _statusMessageNoPublish; + case _PublishCheckResult.needsPublishing: + return _statusNeedsPublish; + case _PublishCheckResult.error: + return _statusMessageError; } } @@ -146,6 +126,7 @@ class PublishCheckCommand extends PluginCommand { } Future _hasValidPublishCheckRun(Directory package) async { + print('Running pub publish --dry-run:'); final io.Process process = await processRunner.start( 'flutter', ['pub', 'publish', '--', '--dry-run'], @@ -198,92 +179,79 @@ class PublishCheckCommand extends PluginCommand { 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'); } - Future<_PublishCheckResult> _passesPublishCheck(Directory package) async { + /// Returns the result of the publish check, or null if the package is marked + /// as unpublishable. + Future<_PublishCheckResult?> _passesPublishCheck(Directory package) async { final String packageName = package.basename; - print('Checking that $packageName can be published.'); - final Pubspec? pubspec = _tryParsePubspec(package); if (pubspec == null) { print('no pubspec'); - return _PublishCheckResult._error; + return _PublishCheckResult.error; } else if (pubspec.publishTo == 'none') { - print('Package $packageName is marked as unpublishable. Skipping.'); - return _PublishCheckResult._published; + return null; } final Version? version = pubspec.version; final _PublishCheckResult alreadyPublishedResult = - await _checkIfAlreadyPublished( + await _checkPublishingStatus( packageName: packageName, version: version); - if (alreadyPublishedResult == _PublishCheckResult._published) { + if (alreadyPublishedResult == _PublishCheckResult.nothingToPublish) { print( 'Package $packageName version: $version has already be published on pub.'); return alreadyPublishedResult; - } else if (alreadyPublishedResult == _PublishCheckResult._error) { + } else if (alreadyPublishedResult == _PublishCheckResult.error) { print('Check pub version failed $packageName'); - return _PublishCheckResult._error; + return _PublishCheckResult.error; } if (await _hasValidPublishCheckRun(package)) { print('Package $packageName is able to be published.'); - return _PublishCheckResult._notPublished; + return _PublishCheckResult.needsPublishing; } else { print('Unable to publish $packageName'); - return _PublishCheckResult._error; + return _PublishCheckResult.error; } } // Check if `packageName` already has `version` published on pub. - Future<_PublishCheckResult> _checkIfAlreadyPublished( + Future<_PublishCheckResult> _checkPublishingStatus( {required String packageName, required Version? version}) async { final PubVersionFinderResponse pubVersionFinderResponse = await _pubVersionFinder.getPackageVersion(package: packageName); switch (pubVersionFinderResponse.result) { case PubVersionFinderResult.success: return pubVersionFinderResponse.versions.contains(version) - ? _PublishCheckResult._published - : _PublishCheckResult._notPublished; + ? _PublishCheckResult.nothingToPublish + : _PublishCheckResult.needsPublishing; case PubVersionFinderResult.fail: print(''' Error fetching version on pub for $packageName. HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} HTTP response: ${pubVersionFinderResponse.httpResponse.body} '''); - return _PublishCheckResult._error; + return _PublishCheckResult.error; case PubVersionFinderResult.noPackageFound: - return _PublishCheckResult._notPublished; + return _PublishCheckResult.needsPublishing; } } - void _setStatus(String status) { - assert(_validStatus.contains(status)); - _machineOutput[_statusKey] = status; - } - - String _prettyJson(Map map) { - return const JsonEncoder.withIndent(' ').convert(_machineOutput); - } - void _printImportantStatusMessage(String message, {required bool isError}) { final String statusMessage = '${isError ? 'ERROR' : 'SUCCESS'}: $message'; if (getBoolArg(_machineFlag)) { print(statusMessage); } else { - final Colorize colorizedMessage = Colorize(statusMessage); if (isError) { - colorizedMessage.red(); + printError(statusMessage); } else { - colorizedMessage.green(); + printSuccess(statusMessage); } - print(colorizedMessage); } } } +/// Possible outcomes of of a publishing check. enum _PublishCheckResult { - _notPublished, - - _published, - - _error, + nothingToPublish, + needsPublishing, + error, } diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index ee5aba5c5f55..917fbc0fd67a 100644 --- a/script/tool/test/common/package_looping_command_test.dart +++ b/script/tool/test/common/package_looping_command_test.dart @@ -56,6 +56,7 @@ void main() { bool failsDuringInit = false, bool warnsDuringInit = false, bool warnsDuringCleanup = false, + bool captureOutput = false, String? customFailureListHeader, String? customFailureListFooter, }) { @@ -80,6 +81,7 @@ void main() { warnsDuringCleanup: warnsDuringCleanup, customFailureListHeader: customFailureListHeader, customFailureListFooter: customFailureListFooter, + captureOutput: captureOutput, gitDir: gitDir, ); } @@ -254,6 +256,7 @@ void main() { expect( output, containsAllInOrder([ + '\n', '${_startErrorColor}The following packages had errors:$_endColor', '$_startErrorColor package_b$_endColor', '$_startErrorColor package_d$_endColor', @@ -285,6 +288,7 @@ void main() { expect( output, containsAllInOrder([ + '\n', '${_startErrorColor}This is a custom header$_endColor', '$_startErrorColor package_b$_endColor', '$_startErrorColor package_d$_endColor', @@ -319,6 +323,7 @@ void main() { expect( output, containsAllInOrder([ + '\n', '${_startErrorColor}The following packages had errors:$_endColor', '$_startErrorColor package_b:\n just one detail$_endColor', '$_startErrorColor package_d:\n first detail\n second detail$_endColor', @@ -326,6 +331,28 @@ void main() { ])); }); + test('is captured, not printed, when requested', () async { + createFakePlugin('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true, captureOutput: true); + final List output = await runCommand(command); + + expect(output, isEmpty); + + // None of the output should be colorized when captured. + const String separator = + '============================================================'; + expect( + command.capturedOutput, + containsAllInOrder([ + '\n$separator\n|| Running for package_a\n$separator\n', + '\n$separator\n|| Running for package_b\n$separator\n', + 'No issues found!', + ])); + }); + test('logs skips', () async { createFakePackage('package_a', packagesDir); final Directory skipPackage = createFakePackage('package_b', packagesDir); @@ -522,11 +549,13 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { this.failsDuringInit = false, this.warnsDuringInit = false, this.warnsDuringCleanup = false, + this.captureOutput = false, ProcessRunner processRunner = const ProcessRunner(), GitDir? gitDir, }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); final List checkedPackages = []; + final List capturedOutput = []; final String? customFailureListHeader; final String? customFailureListFooter; @@ -549,6 +578,9 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { String get failureListFooter => customFailureListFooter ?? super.failureListFooter; + @override + bool captureOutput; + @override final String name = 'loop-test'; @@ -590,6 +622,11 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { logWarning('Warning during completeRun'); } } + + @override + Future handleCapturedOutput(List output) async { + capturedOutput.addAll(output); + } } class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 26938cc92791..a02770ec2e7c 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -51,7 +51,7 @@ void main() { processRunner.processesToReturn.add( MockProcess()..exitCodeCompleter.complete(0), ); - await runner.run(['publish-check']); + await runCapturingPrint(runner, ['publish-check']); expect( processRunner.recordedCalls, @@ -78,7 +78,7 @@ void main() { processRunner.processesToReturn.add(process); expect( - () => runner.run(['publish-check']), + () => runCapturingPrint(runner, ['publish-check']), throwsA(isA()), ); }); @@ -90,7 +90,7 @@ void main() { final MockProcess process = MockProcess(); processRunner.processesToReturn.add(process); - expect(() => runner.run(['publish-check']), + expect(() => runCapturingPrint(runner, ['publish-check']), throwsA(isA())); }); @@ -109,7 +109,9 @@ void main() { processRunner.processesToReturn.add(process); - expect(runner.run(['publish-check', '--allow-pre-release']), + expect( + runCapturingPrint( + runner, ['publish-check', '--allow-pre-release']), completes); }); @@ -128,7 +130,8 @@ void main() { processRunner.processesToReturn.add(process); - expect(runner.run(['publish-check']), throwsA(isA())); + expect(runCapturingPrint(runner, ['publish-check']), + throwsA(isA())); }); test('Success message on stderr is not printed as an error', () async { @@ -197,16 +200,23 @@ void main() { final List output = await runCapturingPrint( runner, ['publish-check', '--machine']); - // ignore: use_raw_strings - expect(output.first, ''' + expect(output.first, r''' { "status": "no-publish", "humanMessage": [ - "Checking that no_publish_a can be published.", + "\n============================================================\n|| Running for no_publish_a\n============================================================\n", "Package no_publish_a version: 0.1.0 has already be published on pub.", - "Checking that no_publish_b can be published.", + "\n============================================================\n|| Running for no_publish_b\n============================================================\n", "Package no_publish_b version: 0.2.0 has already be published on pub.", - "SUCCESS: All packages passed publish check!" + "\n", + "------------------------------------------------------------", + "Run overview:", + " no_publish_a - ran", + " no_publish_b - ran", + "", + "Ran for 2 package(s)", + "\n", + "No issues found!" ] }'''); }); @@ -257,16 +267,24 @@ void main() { final List output = await runCapturingPrint( runner, ['publish-check', '--machine']); - // ignore: use_raw_strings - expect(output.first, ''' + expect(output.first, r''' { "status": "needs-publish", "humanMessage": [ - "Checking that no_publish_a can be published.", + "\n============================================================\n|| Running for no_publish_a\n============================================================\n", "Package no_publish_a version: 0.1.0 has already be published on pub.", - "Checking that no_publish_b can be published.", + "\n============================================================\n|| Running for no_publish_b\n============================================================\n", + "Running pub publish --dry-run:", "Package no_publish_b is able to be published.", - "SUCCESS: All packages passed publish check!" + "\n", + "------------------------------------------------------------", + "Run overview:", + " no_publish_a - ran", + " no_publish_b - ran", + "", + "Ran for 2 package(s)", + "\n", + "No issues found!" ] }'''); }); @@ -328,19 +346,22 @@ void main() { }); expect(hasError, isTrue); - // ignore: use_raw_strings - expect(output.first, ''' + expect(output.first, r''' { "status": "error", "humanMessage": [ - "Checking that no_publish_a can be published.", - "Failed to parse `pubspec.yaml` at /packages/no_publish_a/pubspec.yaml: ParsedYamlException: line 1, column 1: Not a map\\n ╷\\n1 │ bad-yaml\\n │ ^^^^^^^^\\n ╵}", + "\n============================================================\n|| Running for no_publish_a\n============================================================\n", + "Failed to parse `pubspec.yaml` at /packages/no_publish_a/pubspec.yaml: ParsedYamlException: line 1, column 1: Not a map\n ╷\n1 │ bad-yaml\n │ ^^^^^^^^\n ╵}", "no pubspec", - "Checking that no_publish_b can be published.", + "\n============================================================\n|| Running for no_publish_b\n============================================================\n", "url https://pub.dev/packages/no_publish_b.json", "no_publish_b.json", + "Running pub publish --dry-run:", "Package no_publish_b is able to be published.", - "ERROR: The following 1 package(s) failed the publishing check:\\nMemoryDirectory: '/packages/no_publish_a'" + "\n", + "The following packages had errors:", + " no_publish_a", + "See above for full details." ] }'''); }); From 3477be294a167f6b78ba6c0e58d25efec337a094 Mon Sep 17 00:00:00 2001 From: vladb <53141637+hiddenfloret@users.noreply.github.com> Date: Fri, 2 Jul 2021 19:01:03 +0200 Subject: [PATCH 104/364] [in_app_purchase] Initialize SKError with correct data type (#4113) --- .../in_app_purchase_ios/CHANGELOG.md | 6 +- .../sk_payment_queue_wrapper.dart | 13 ++-- .../in_app_purchase_ios/pubspec.yaml | 2 +- .../sk_payment_queue_delegate_api_test.dart | 59 +++++++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index acbe995877dd..d10edc97a82e 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.2+1 + +* Fix wrong data type when cancelling user credentials dialog. + ## 0.1.2 * Added countryCode to the SKPriceLocaleWrapper. @@ -12,7 +16,7 @@ ## 0.1.0+2 -* Changed the iOS payment queue handler in such a way that it only adds a listener to the `SKPaymentQueue` when there +* Changed the iOS payment queue handler in such a way that it only adds a listener to the `SKPaymentQueue` when there is a listener to the Dart `purchaseStream`. ## 0.1.0+1 diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart index c39ad9efddd7..079e75078037 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.dart @@ -66,7 +66,7 @@ class SKPaymentQueueWrapper { /// addTransactionObserver:]`](https://developer.apple.com/documentation/storekit/skpaymentqueue/1506042-addtransactionobserver?language=objc). void setTransactionObserver(SKTransactionObserverWrapper observer) { _observer = observer; - channel.setMethodCallHandler(_handleObserverCallbacks); + channel.setMethodCallHandler(handleObserverCallbacks); } /// Instructs the iOS implementation to register a transaction observer and @@ -208,8 +208,12 @@ class SKPaymentQueueWrapper { .invokeMethod('-[SKPaymentQueue showPriceConsentIfNeeded]'); } - // Triage a method channel call from the platform and triggers the correct observer method. - Future _handleObserverCallbacks(MethodCall call) async { + /// Triage a method channel call from the platform and triggers the correct observer method. + /// + /// This method is public for testing purposes only and should not be used + /// outside this class. + @visibleForTesting + Future handleObserverCallbacks(MethodCall call) async { assert(_observer != null, '[in_app_purchase]: (Fatal)The observer has not been set but we received a purchase transaction notification. Please ensure the observer has been set using `setTransactionObserver`. Make sure the observer is added right at the App Launch.'); final SKTransactionObserverWrapper observer = _observer!; @@ -232,7 +236,8 @@ class SKPaymentQueueWrapper { } case 'restoreCompletedTransactionsFailed': { - SKError error = SKError.fromJson(call.arguments); + SKError error = + SKError.fromJson(Map.from(call.arguments)); return Future(() { observer.restoreCompletedTransactionsFailed(error: error); }); diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index b73ad8492647..69afe52b6ac3 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.2 +version: 0.1.2+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart index b61411dfffa4..ca2b3364d680 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart @@ -68,6 +68,65 @@ void main() { }, ); }); + + test( + 'handleObserverCallbacks should call SKTransactionObserverWrapper.restoreCompletedTransactionsFailed', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestTransactionObserverWrapper testObserver = + TestTransactionObserverWrapper(); + queue.setTransactionObserver(testObserver); + + final arguments = { + 'code': 100, + 'domain': 'domain', + 'userInfo': {'error': 'underlying_error'}, + }; + + await queue.handleObserverCallbacks( + MethodCall('restoreCompletedTransactionsFailed', arguments), + ); + + expect( + testObserver.log, + { + equals('restoreCompletedTransactionsFailed'), + }, + ); + }); +} + +class TestTransactionObserverWrapper extends SKTransactionObserverWrapper { + final List log = []; + + @override + void updatedTransactions( + {required List transactions}) { + log.add('updatedTransactions'); + } + + @override + void removedTransactions( + {required List transactions}) { + log.add('removedTransactions'); + } + + @override + void restoreCompletedTransactionsFailed({required SKError error}) { + log.add('restoreCompletedTransactionsFailed'); + } + + @override + void paymentQueueRestoreCompletedTransactionsFinished() { + log.add('paymentQueueRestoreCompletedTransactionsFinished'); + } + + @override + bool shouldAddStorePayment( + {required SKPaymentWrapper payment, required SKProductWrapper product}) { + log.add('shouldAddStorePayment'); + return false; + } } class TestPaymentQueueDelegate extends SKPaymentQueueDelegateWrapper { From 2745bad5874679bc5686d2a03a89d2fb2b980e96 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 2 Jul 2021 10:49:04 -0700 Subject: [PATCH 105/364] [flutter_plugin_tool] Add more failure test coverage (#4132) Many commands had insufficient failure testing. This adds new tests that ensure that for every Process call, at least one test fails if a failure from that process were ignored (with the exception of calls in the `publish` command, which has a custom process mocking system, so was out of scope here; it already has more coverage than most tests did though.) For a few existing failure tests, adds output checks to ensure that they are testing for the *right* failures. Other changes: - Adds convenience constructors to MockProcess for the common cases of a mock process that just exits with a 0 or 1 status, to reduce test verbosity. - Fixes a few bugs that were found by the new tests. - Minor test cleanup, especially cases where a mock process was being set up just to make all calls succeed, which is the default as of recent changes. --- script/tool/lib/src/analyze_command.dart | 4 +- .../lib/src/firebase_test_lab_command.dart | 6 +- script/tool/lib/src/java_test_command.dart | 6 +- .../tool/lib/src/version_check_command.dart | 2 +- script/tool/test/analyze_command_test.dart | 98 +++++++--- .../test/build_examples_command_test.dart | 180 ++++++++---------- .../test/drive_examples_command_test.dart | 19 +- .../test/firebase_test_lab_command_test.dart | 167 +++++++++++++++- script/tool/test/java_test_command_test.dart | 16 +- .../tool/test/lint_podspecs_command_test.dart | 47 +++-- script/tool/test/mocks.dart | 12 ++ .../tool/test/publish_check_command_test.dart | 25 +-- script/tool/test/test_command_test.dart | 74 +++++++ script/tool/test/xctest_command_test.dart | 121 ++++++++---- 14 files changed, 557 insertions(+), 220 deletions(-) diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index adaeb1e616e7..b8458da5228c 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -66,7 +66,7 @@ class AnalyzeCommand extends PackageLoopingCommand { } printError( - 'Found an extra analysis_options.yaml in ${file.absolute.path}.'); + 'Found an extra analysis_options.yaml at ${file.absolute.path}.'); printError( 'If this was deliberate, pass the package to the analyze command ' 'with the --$_customAnalysisFlag flag and try again.'); @@ -105,7 +105,7 @@ class AnalyzeCommand extends PackageLoopingCommand { print('Fetching dependencies...'); if (!await _runPackagesGetOnTargetPackages()) { - printError('Unabled to get dependencies.'); + printError('Unable to get dependencies.'); throw ToolExit(_exitPackagesGetFailed); } diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 3d68c85fbd13..8253ceeda86b 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -135,13 +135,13 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { // Ensures that gradle wrapper exists if (!await _ensureGradleWrapperExists(androidDirectory)) { - PackageResult.fail(['Unable to build example apk']); + return PackageResult.fail(['Unable to build example apk']); } await _configureFirebaseProject(); if (!await _runGradle(androidDirectory, 'app:assembleAndroidTest')) { - PackageResult.fail(['Unable to assemble androidTest']); + return PackageResult.fail(['Unable to assemble androidTest']); } final List errors = []; @@ -236,7 +236,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { : null; final int exitCode = await processRunner.runAndStream( - p.join(directory.path, _gradleWrapper), + directory.childFile(_gradleWrapper).path, [ target, '-Pverbose=true', diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart index 1534fccff33c..352197be3057 100644 --- a/script/tool/lib/src/java_test_command.dart +++ b/script/tool/lib/src/java_test_command.dart @@ -54,7 +54,8 @@ class JavaTestCommand extends PackageLoopingCommand { print('\nRUNNING JAVA TESTS for $exampleName'); final Directory androidDirectory = example.childDirectory('android'); - if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { + final File gradleFile = androidDirectory.childFile(_gradleWrapper); + if (!gradleFile.existsSync()) { printError('ERROR: Run "flutter build apk" on $exampleName, or run ' 'this tool\'s "build-examples --apk" command, ' 'before executing tests.'); @@ -63,8 +64,7 @@ class JavaTestCommand extends PackageLoopingCommand { } final int exitCode = await processRunner.runAndStream( - p.join(androidDirectory.path, _gradleWrapper), - ['testDebugUnitTest', '--info'], + gradleFile.path, ['testDebugUnitTest', '--info'], workingDir: androidDirectory); if (exitCode != 0) { errors.add('$exampleName tests failed.'); diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index b26a7adc9b53..f0902f016833 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -128,7 +128,7 @@ class VersionCheckCommand extends PackageLoopingCommand { 'that intentionally has no version should be marked ' '"publish_to: none".'); // No remaining checks make sense, so fail immediately. - PackageResult.fail(['No pubspec.yaml version.']); + return PackageResult.fail(['No pubspec.yaml version.']); } final List errors = []; diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 757adb622678..768463f0a5a2 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as io; + import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -33,9 +35,6 @@ void main() { final Directory plugin1Dir = createFakePlugin('a', packagesDir); final Directory plugin2Dir = createFakePlugin('b', packagesDir); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; await runCapturingPrint(runner, ['analyze']); expect( @@ -55,9 +54,6 @@ void main() { test('skips flutter pub get for examples', () async { final Directory plugin1Dir = createFakePlugin('a', packagesDir); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; await runCapturingPrint(runner, ['analyze']); expect( @@ -74,9 +70,6 @@ void main() { final Directory plugin1Dir = createFakePlugin('a', packagesDir); final Directory plugin2Dir = createFakePlugin('example', packagesDir); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; await runCapturingPrint(runner, ['analyze']); expect( @@ -96,9 +89,6 @@ void main() { test('uses a separate analysis sdk', () async { final Directory pluginDir = createFakePlugin('a', packagesDir); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; await runCapturingPrint( runner, ['analyze', '--analysis-sdk', 'foo/bar/baz']); @@ -124,25 +114,46 @@ void main() { createFakePlugin('foo', packagesDir, extraFiles: ['analysis_options.yaml']); - await expectLater(() => runCapturingPrint(runner, ['analyze']), - throwsA(isA())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['analyze'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Found an extra analysis_options.yaml at /packages/foo/analysis_options.yaml'), + ]), + ); }); test('fails .analysis_options', () async { createFakePlugin('foo', packagesDir, extraFiles: ['.analysis_options']); - await expectLater(() => runCapturingPrint(runner, ['analyze']), - throwsA(isA())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['analyze'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Found an extra analysis_options.yaml at /packages/foo/.analysis_options'), + ]), + ); }); test('takes an allow list', () async { final Directory pluginDir = createFakePlugin('foo', packagesDir, extraFiles: ['analysis_options.yaml']); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; await runCapturingPrint( runner, ['analyze', '--custom-analysis', 'foo']); @@ -161,14 +172,55 @@ void main() { createFakePlugin('foo', packagesDir, extraFiles: ['analysis_options.yaml']); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; - await expectLater( () => runCapturingPrint( runner, ['analyze', '--custom-analysis', '']), throwsA(isA())); }); }); + + test('fails if "packages get" fails', () async { + createFakePlugin('foo', packagesDir); + + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess.failing() // flutter packages get + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['analyze'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to get dependencies'), + ]), + ); + }); + + test('fails if "analyze" fails', () async { + createFakePlugin('foo', packagesDir); + + processRunner.mockProcessesForExecutable['dart'] = [ + MockProcess.failing() // dart analyze + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['analyze'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages had errors:'), + contains(' foo'), + ]), + ); + }); } diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 218f448242b6..c0c90a15c71c 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as io; + import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -12,6 +14,7 @@ import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; void main() { @@ -36,16 +39,49 @@ void main() { }); test('fails if no plaform flags are passed', () async { + Error? commandError; + final List output = await runCapturingPrint( + runner, ['build-examples'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( - () => runCapturingPrint(runner, ['build-examples']), - throwsA(isA()), - ); + output, + containsAllInOrder([ + contains('At least one platform must be provided'), + ])); + }); + + test('fails if building fails', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess.failing() // flutter packages get + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['build-examples', '--ios'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages had errors:'), + contains(' plugin:\n' + ' plugin/example (iOS)'), + ])); }); test('building for iOS when plugin is not set up for iOS results in no-op', () async { - createFakePlugin('plugin', packagesDir, - extraFiles: ['example/test']); + createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint(runner, ['build-examples', '--ios']); @@ -64,16 +100,10 @@ void main() { }); test('building for ios', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - platformSupport: { - kPlatformIos: PlatformSupport.inline - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -108,9 +138,7 @@ void main() { test( 'building for Linux when plugin is not set up for Linux results in no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ]); + createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint( runner, ['build-examples', '--linux']); @@ -129,16 +157,10 @@ void main() { }); test('building for Linux', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - platformSupport: { - kPlatformLinux: PlatformSupport.inline, - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformLinux: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -165,9 +187,7 @@ void main() { test('building for macos with no implementation results in no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ]); + createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint( runner, ['build-examples', '--macos']); @@ -186,17 +206,10 @@ void main() { }); test('building for macos', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - 'example/macos/macos.swift', - ], - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -222,9 +235,7 @@ void main() { }); test('building for web with no implementation results in no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ]); + createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint(runner, ['build-examples', '--web']); @@ -243,17 +254,10 @@ void main() { }); test('building for web', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - 'example/web/index.html', - ], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformWeb: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -281,9 +285,7 @@ void main() { test( 'building for Windows when plugin is not set up for Windows results in no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ]); + createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint( runner, ['build-examples', '--windows']); @@ -302,16 +304,10 @@ void main() { }); test('building for windows', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - platformSupport: { - kPlatformWindows: PlatformSupport.inline - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformWindows: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -339,9 +335,7 @@ void main() { test( 'building for Android when plugin is not set up for Android results in no-op', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ]); + createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint(runner, ['build-examples', '--apk']); @@ -360,16 +354,10 @@ void main() { }); test('building for android', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -397,16 +385,10 @@ void main() { }); test('enable-experiment flag for Android', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -425,16 +407,10 @@ void main() { }); test('enable-experiment flag for ios', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - platformSupport: { - kPlatformIos: PlatformSupport.inline - }, - ); + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline + }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index eeac96e56e60..d22d95f14d57 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -52,8 +52,7 @@ void main() { ]; final String output = '''[${devices.join(',')}]'''; - final MockProcess mockDevicesProcess = MockProcess(); - mockDevicesProcess.exitCodeCompleter.complete(0); + final MockProcess mockDevicesProcess = MockProcess.succeeding(); mockDevicesProcess.stdoutController.close(); // ignore: unawaited_futures processRunner.mockProcessesForExecutable['flutter'] = [ mockDevicesProcess @@ -115,12 +114,10 @@ void main() { }); test('fails for iOS if getting devices fails', () async { - setMockFlutterDevicesOutput(hasIosDevice: false); - // Simulate failure from `flutter devices`. - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(1); - processRunner.processToReturn = mockProcess; + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess.failing() + ]; Error? commandError; final List output = await runCapturingPrint( @@ -935,9 +932,11 @@ void main() { ); // Simulate failure from `flutter drive`. - final MockProcess mockDriveProcess = MockProcess(); - mockDriveProcess.exitCodeCompleter.complete(1); - processRunner.processToReturn = mockDriveProcess; + processRunner.mockProcessesForExecutable['flutter'] = [ + // No mock for 'devices', since it's running for macOS. + MockProcess.failing(), // 'drive' #1 + MockProcess.failing(), // 'drive' #2 + ]; Error? commandError; final List output = diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index 711c383f2d5d..0199eba95983 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -34,10 +34,8 @@ void main() { }); test('fails if gcloud auth fails', () async { - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(1); processRunner.mockProcessesForExecutable['gcloud'] = [ - mockProcess + MockProcess.failing() ]; createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', @@ -60,13 +58,9 @@ void main() { }); test('retries gcloud set', () async { - final MockProcess mockAuthProcess = MockProcess(); - mockAuthProcess.exitCodeCompleter.complete(0); - final MockProcess mockConfigProcess = MockProcess(); - mockConfigProcess.exitCodeCompleter.complete(1); processRunner.mockProcessesForExecutable['gcloud'] = [ - mockAuthProcess, - mockConfigProcess, + MockProcess.succeeding(), // auth + MockProcess.failing(), // config ]; createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', @@ -158,6 +152,52 @@ void main() { ); }); + test('fails if a test fails', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/bar_test.dart', + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + processRunner.mockProcessesForExecutable['gcloud'] = [ + MockProcess.succeeding(), // auth + MockProcess.succeeding(), // config + MockProcess.failing(), // integration test #1 + MockProcess.succeeding(), // integration test #2 + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Testing example/integration_test/bar_test.dart...'), + contains('Testing example/integration_test/foo_test.dart...'), + contains('plugin:\n' + ' example/integration_test/bar_test.dart failed tests'), + ]), + ); + }); + test('skips packages with no androidTest directory', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', @@ -253,6 +293,115 @@ void main() { ); }); + test('fails if building to generate gradlew fails', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess.failing() // flutter build + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to build example apk'), + ])); + }); + + test('fails if assembleAndroidTest fails', () async { + final Directory pluginDir = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to assemble androidTest'), + ])); + }); + + test('fails if assembleDebug fails', () async { + final Directory pluginDir = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess.succeeding(), // assembleAndroidTest + MockProcess.failing(), // assembleDebug + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Could not build example/integration_test/foo_test.dart'), + contains('The following packages had errors:'), + contains(' plugin:\n' + ' example/integration_test/foo_test.dart failed to build'), + ])); + }); + test('experimental flag', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index 227327ab4e6c..9ae959710984 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as io; + import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -118,7 +120,7 @@ void main() { }); test('fails when a test fails', () async { - createFakePlugin( + final Directory pluginDir = createFakePlugin( 'plugin1', packagesDir, platformSupport: { @@ -130,10 +132,14 @@ void main() { ], ); - // Simulate failure from `gradlew`. - final MockProcess mockDriveProcess = MockProcess(); - mockDriveProcess.exitCodeCompleter.complete(1); - processRunner.processToReturn = mockDriveProcess; + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess.failing() + ]; Error? commandError; final List output = await runCapturingPrint( diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index c61d6a9d9281..1236ec0f5013 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -38,10 +38,6 @@ void main() { runner = CommandRunner('podspec_test', 'Test for $LintPodspecsCommand'); runner.addCommand(command); - final MockProcess mockLintProcess = MockProcess(); - mockLintProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockLintProcess; - processRunner.recordedCalls.clear(); }); test('only runs on macOS', () async { @@ -79,6 +75,10 @@ void main() { ], ); + processRunner.mockProcessesForExecutable['pod'] = [ + MockProcess.succeeding(), + MockProcess.succeeding(), + ]; processRunner.resultStdout = 'Foo'; processRunner.resultStderr = 'Bar'; @@ -167,10 +167,8 @@ void main() { extraFiles: ['plugin1.podspec']); // Simulate failure from `which pod`. - final MockProcess mockWhichProcess = MockProcess(); - mockWhichProcess.exitCodeCompleter.complete(1); processRunner.mockProcessesForExecutable['which'] = [ - mockWhichProcess + MockProcess.failing(), ]; Error? commandError; @@ -190,15 +188,42 @@ void main() { )); }); - test('fails if linting fails', () async { + test('fails if linting as a framework fails', () async { createFakePlugin('plugin1', packagesDir, extraFiles: ['plugin1.podspec']); // Simulate failure from `pod`. - final MockProcess mockDriveProcess = MockProcess(); - mockDriveProcess.exitCodeCompleter.complete(1); processRunner.mockProcessesForExecutable['pod'] = [ - mockDriveProcess + MockProcess.failing(), + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['podspecs'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder( + [ + contains('The following packages had errors:'), + contains('plugin1:\n' + ' plugin1.podspec') + ], + )); + }); + + test('fails if linting as a static library fails', () async { + createFakePlugin('plugin1', packagesDir, + extraFiles: ['plugin1.podspec']); + + // Simulate failure from the second call to `pod`. + processRunner.mockProcessesForExecutable['pod'] = [ + MockProcess.succeeding(), + MockProcess.failing(), ]; Error? commandError; diff --git a/script/tool/test/mocks.dart b/script/tool/test/mocks.dart index ba6a03da7bcf..02b00658398e 100644 --- a/script/tool/test/mocks.dart +++ b/script/tool/test/mocks.dart @@ -17,6 +17,18 @@ class MockPlatform extends Mock implements Platform { } class MockProcess extends Mock implements io.Process { + MockProcess(); + + /// A mock process that terminates with exitCode 0. + MockProcess.succeeding() { + exitCodeCompleter.complete(0); + } + + /// A mock process that terminates with exitCode 1. + MockProcess.failing() { + exitCodeCompleter.complete(1); + } + final Completer exitCodeCompleter = Completer(); final StreamController> stdoutController = StreamController>(); diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index a02770ec2e7c..5140316b4511 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -46,10 +46,10 @@ void main() { createFakePlugin('plugin_tools_test_package_b', packagesDir); processRunner.processesToReturn.add( - MockProcess()..exitCodeCompleter.complete(0), + MockProcess.succeeding(), ); processRunner.processesToReturn.add( - MockProcess()..exitCodeCompleter.complete(0), + MockProcess.succeeding(), ); await runCapturingPrint(runner, ['publish-check']); @@ -70,10 +70,9 @@ void main() { test('fail on negative test', () async { createFakePlugin('plugin_tools_test_package_a', packagesDir); - final MockProcess process = MockProcess(); + final MockProcess process = MockProcess.failing(); process.stdoutController.close(); // ignore: unawaited_futures process.stderrController.close(); // ignore: unawaited_futures - process.exitCodeCompleter.complete(1); processRunner.processesToReturn.add(process); @@ -100,13 +99,11 @@ void main() { const String preReleaseOutput = 'Package has 1 warning.' 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'; - final MockProcess process = MockProcess(); + final MockProcess process = MockProcess.failing(); process.stdoutController.add(preReleaseOutput.codeUnits); process.stdoutController.close(); // ignore: unawaited_futures process.stderrController.close(); // ignore: unawaited_futures - process.exitCodeCompleter.complete(1); - processRunner.processesToReturn.add(process); expect( @@ -121,13 +118,11 @@ void main() { const String preReleaseOutput = 'Package has 1 warning.' 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'; - final MockProcess process = MockProcess(); + final MockProcess process = MockProcess.failing(); process.stdoutController.add(preReleaseOutput.codeUnits); process.stdoutController.close(); // ignore: unawaited_futures process.stderrController.close(); // ignore: unawaited_futures - process.exitCodeCompleter.complete(1); - processRunner.processesToReturn.add(process); expect(runCapturingPrint(runner, ['publish-check']), @@ -139,13 +134,11 @@ void main() { const String publishOutput = 'Package has 0 warnings.'; - final MockProcess process = MockProcess(); + final MockProcess process = MockProcess.succeeding(); process.stderrController.add(publishOutput.codeUnits); process.stdoutController.close(); // ignore: unawaited_futures process.stderrController.close(); // ignore: unawaited_futures - process.exitCodeCompleter.complete(0); - processRunner.processesToReturn.add(process); final List output = @@ -195,7 +188,7 @@ void main() { createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); processRunner.processesToReturn.add( - MockProcess()..exitCodeCompleter.complete(0), + MockProcess.succeeding(), ); final List output = await runCapturingPrint( runner, ['publish-check', '--machine']); @@ -261,7 +254,7 @@ void main() { createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); processRunner.processesToReturn.add( - MockProcess()..exitCodeCompleter.complete(0), + MockProcess.succeeding(), ); final List output = await runCapturingPrint( @@ -334,7 +327,7 @@ void main() { await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); processRunner.processesToReturn.add( - MockProcess()..exitCodeCompleter.complete(0), + MockProcess.succeeding(), ); bool hasError = false; diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 861a485f9281..831910aad1fb 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as io; + import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -10,6 +12,7 @@ import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/test_command.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; void main() { @@ -49,6 +52,32 @@ void main() { ); }); + test('fails when Flutter tests fail', () async { + createFakePlugin('plugin1', packagesDir, + extraFiles: ['test/empty_test.dart']); + createFakePlugin('plugin2', packagesDir, + extraFiles: ['test/empty_test.dart']); + + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess.failing(), // plugin 1 test + MockProcess.succeeding(), // plugin 2 test + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['test'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Tests for the following packages are failing'), + contains(' * plugin1'), + ])); + }); + test('skips testing plugins without test directory', () async { createFakePlugin('plugin1', packagesDir); final Directory plugin2Dir = createFakePlugin('plugin2', packagesDir, @@ -90,6 +119,51 @@ void main() { ); }); + test('fails when getting non-Flutter package dependencies fails', () async { + createFakePackage('a_package', packagesDir, + extraFiles: ['test/empty_test.dart']); + + processRunner.mockProcessesForExecutable['dart'] = [ + MockProcess.failing(), // dart pub get + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['test'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Tests for the following packages are failing'), + ])); + }); + + test('fails when non-Flutter tests fail', () async { + createFakePackage('a_package', packagesDir, + extraFiles: ['test/empty_test.dart']); + + processRunner.mockProcessesForExecutable['dart'] = [ + MockProcess.succeeding(), // dart pub get + MockProcess.failing(), // dart pub run test + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['test'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Tests for the following packages are failing'), + ])); + }); + test('runs on Chrome for web plugins', () async { final Directory pluginDir = createFakePlugin( 'plugin', diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 61d303120275..10329b18980c 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:convert'; +import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; @@ -105,9 +106,18 @@ void main() { }); test('Fails if no platforms are provided', () async { + Error? commandError; + final List output = await runCapturingPrint( + runner, ['xctest'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( - () => runCapturingPrint(runner, ['xctest']), - throwsA(isA()), + output, + containsAllInOrder([ + contains('At least one platform flag must be provided'), + ]), ); }); @@ -119,9 +129,6 @@ void main() { kPlatformMacos: PlatformSupport.inline, }); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; final List output = await runCapturingPrint(runner, ['xctest', '--ios', _kDestination, 'foo_destination']); expect( @@ -138,9 +145,6 @@ void main() { kPlatformIos: PlatformSupport.federated }); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; final List output = await runCapturingPrint(runner, ['xctest', '--ios', _kDestination, 'foo_destination']); expect( @@ -161,9 +165,7 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; + processRunner.processToReturn = MockProcess.succeeding(); processRunner.resultStdout = '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ @@ -215,14 +217,12 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; final Map schemeCommandResult = { 'project': { 'targets': ['bar_scheme', 'foo_scheme'] } }; + processRunner.processToReturn = MockProcess.succeeding(); // For simplicity of the test, we combine all the mock results into a single mock result, each internal command // will get this result and they should still be able to parse them correctly. processRunner.resultStdout = @@ -253,6 +253,43 @@ void main() { pluginExampleDirectory.path), ])); }); + + test('fails if xcrun fails', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'xctest', + '--ios', + _kDestination, + 'foo_destination', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages are failing XCTests:'), + contains(' plugin'), + ])); + }); }); group('macOS', () { @@ -265,9 +302,6 @@ void main() { ], ); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; final List output = await runCapturingPrint(runner, ['xctest', '--macos', _kDestination, 'foo_destination']); expect( @@ -284,9 +318,6 @@ void main() { kPlatformMacos: PlatformSupport.federated, }); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; final List output = await runCapturingPrint(runner, ['xctest', '--macos', _kDestination, 'foo_destination']); expect( @@ -307,9 +338,7 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; + processRunner.processToReturn = MockProcess.succeeding(); processRunner.resultStdout = '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ @@ -342,6 +371,36 @@ void main() { pluginExampleDirectory.path), ])); }); + + test('fails if xcrun fails', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = + '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['xctest', '--macos'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages are failing XCTests:'), + contains(' plugin'), + ]), + ); + }); }); group('combined', () { @@ -357,9 +416,7 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; + processRunner.processToReturn = MockProcess.succeeding(); processRunner.resultStdout = '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ @@ -426,9 +483,7 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; + processRunner.processToReturn = MockProcess.succeeding(); processRunner.resultStdout = '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ @@ -478,9 +533,7 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; + processRunner.processToReturn = MockProcess.succeeding(); processRunner.resultStdout = '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ @@ -526,9 +579,7 @@ void main() { 'example/test', ]); - final MockProcess mockProcess = MockProcess(); - mockProcess.exitCodeCompleter.complete(0); - processRunner.processToReturn = mockProcess; + processRunner.processToReturn = MockProcess.succeeding(); processRunner.resultStdout = '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ From f466cccab3abb9f8151b0489c496acdd75c836bf Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Sun, 4 Jul 2021 09:26:28 -0700 Subject: [PATCH 106/364] [flutter_plugin_tools] Add --packages, and deprecated --plugins (#4134) Most of the tool operates on packages in general, and the targetting done currently by the `--plugins` flag is not actually restricted to plugins, so this makes the name less confusing. Part of https://github.com/flutter/flutter/issues/83413 --- script/tool/CHANGELOG.md | 2 + script/tool/README.md | 10 +-- .../tool/lib/src/common/plugin_command.dart | 12 ++-- .../tool/test/common/plugin_command_test.dart | 70 ++++++++++++++++--- script/tool/test/list_command_test.dart | 10 +-- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index db9ecf493020..9e9538ce554f 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -7,6 +7,8 @@ new output format. - Fixed some cases where a failure in a command for a single package would immediately abort the test. +- Deprecated `--plugins` in favor of new `--packages`. `--plugins` continues to + work for now, but will be removed in the future. ## 0.3.0 diff --git a/script/tool/README.md b/script/tool/README.md index c0ee8756e16b..5629dc50646b 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -58,21 +58,21 @@ Note that the `plugins` argument, despite the name, applies to any package. ```sh cd -dart run ./script/tool/bin/flutter_plugin_tools.dart format --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart format --packages plugin_name ``` ### Run the Dart Static Analyzer ```sh cd -dart run ./script/tool/bin/flutter_plugin_tools.dart analyze --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart analyze --packages plugin_name ``` ### Run Dart Unit Tests ```sh cd -dart run ./script/tool/bin/flutter_plugin_tools.dart test --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart test --packages plugin_name ``` ### Run XCTests @@ -80,9 +80,9 @@ dart run ./script/tool/bin/flutter_plugin_tools.dart test --plugins plugin_name ```sh cd # For iOS: -dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --ios --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --ios --packages plugin_name # For macOS: -dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --macos --plugins plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --macos --packages plugin_name ``` ### Publish a Release diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 43d0d0b822c7..74f607dde7cf 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -24,11 +24,12 @@ abstract class PluginCommand extends Command { GitDir? gitDir, }) : _gitDir = gitDir { argParser.addMultiOption( - _pluginsArg, + _packagesArg, splitCommas: true, help: - 'Specifies which plugins the command should run on (before sharding).', - valueHelp: 'plugin1,plugin2,...', + 'Specifies which packages the command should run on (before sharding).\n', + valueHelp: 'package1,package2,...', + aliases: [_pluginsArg], ); argParser.addOption( _shardIndexArg, @@ -51,7 +52,7 @@ abstract class PluginCommand extends Command { ); argParser.addFlag(_runOnChangedPackagesArg, help: 'Run the command on changed packages/plugins.\n' - 'If the $_pluginsArg is specified, this flag is ignored.\n' + 'If the $_packagesArg is specified, this flag is ignored.\n' 'If no packages have changed, or if there have been changes that may\n' 'affect all packages, the command runs on all packages.\n' 'The packages excluded with $_excludeArg is also excluded even if changed.\n' @@ -63,6 +64,7 @@ abstract class PluginCommand extends Command { } static const String _pluginsArg = 'plugins'; + static const String _packagesArg = 'packages'; static const String _shardIndexArg = 'shardIndex'; static const String _shardCountArg = 'shardCount'; static const String _excludeArg = 'exclude'; @@ -203,7 +205,7 @@ abstract class PluginCommand extends Command { /// is a sibling of the packages directory. This is used for a small number /// of packages in the flutter/packages repository. Stream _getAllPlugins() async* { - Set plugins = Set.from(getStringListArg(_pluginsArg)); + Set plugins = Set.from(getStringListArg(_packagesArg)); final Set excludedPlugins = Set.from(getStringListArg(_excludeArg)); final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 0c949da07dba..3f1f1adc4c19 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -69,6 +69,22 @@ void main() { expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); }); + test('includes both plugins and packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + final Directory package3 = createFakePackage('package3', packagesDir); + final Directory package4 = createFakePackage('package4', packagesDir); + await runCapturingPrint(runner, ['sample']); + expect( + plugins, + unorderedEquals([ + plugin1.path, + plugin2.path, + package3.path, + package4.path, + ])); + }); + test('all plugins includes third_party/packages', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); @@ -79,15 +95,48 @@ void main() { unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); }); - test('exclude plugins when plugins flag is specified', () async { + test('--packages limits packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + createFakePackage('package3', packagesDir); + final Directory package4 = createFakePackage('package4', packagesDir); + await runCapturingPrint( + runner, ['sample', '--packages=plugin1,package4']); + expect( + plugins, + unorderedEquals([ + plugin1.path, + package4.path, + ])); + }); + + test('--plugins acts as an alias to --packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + createFakePackage('package3', packagesDir); + final Directory package4 = createFakePackage('package4', packagesDir); + await runCapturingPrint( + runner, ['sample', '--plugins=plugin1,package4']); + expect( + plugins, + unorderedEquals([ + plugin1.path, + package4.path, + ])); + }); + + test('exclude packages when packages flag is specified', () async { createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - await runCapturingPrint(runner, - ['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']); + await runCapturingPrint(runner, [ + 'sample', + '--packages=plugin1,plugin2', + '--exclude=plugin1' + ]); expect(plugins, unorderedEquals([plugin2.path])); }); - test('exclude plugins when plugins flag isn\'t specified', () async { + test('exclude packages when packages flag isn\'t specified', () async { createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); await runCapturingPrint( @@ -95,24 +144,24 @@ void main() { expect(plugins, unorderedEquals([])); }); - test('exclude federated plugins when plugins flag is specified', () async { + test('exclude federated plugins when packages flag is specified', () async { createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ 'sample', - '--plugins=federated/plugin1,plugin2', + '--packages=federated/plugin1,plugin2', '--exclude=federated/plugin1' ]); expect(plugins, unorderedEquals([plugin2.path])); }); - test('exclude entire federated plugins when plugins flag is specified', + test('exclude entire federated plugins when packages flag is specified', () async { createFakePlugin('plugin1', packagesDir.childDirectory('federated')); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ 'sample', - '--plugins=federated/plugin1,plugin2', + '--packages=federated/plugin1,plugin2', '--exclude=federated' ]); expect(plugins, unorderedEquals([plugin2.path])); @@ -315,7 +364,8 @@ packages/plugin1/plugin1_web/plugin1_web.dart expect(plugins, unorderedEquals([plugin1.path])); }); - test('--plugins flag overrides the behavior of --run-on-changed-packages', + test( + '--packages flag overrides the behavior of --run-on-changed-packages', () async { gitDiffResponse = ''' packages/plugin1/plugin1.dart @@ -328,7 +378,7 @@ packages/plugin3/plugin3.dart createFakePlugin('plugin3', packagesDir); await runCapturingPrint(runner, [ 'sample', - '--plugins=plugin1,plugin2', + '--packages=plugin1,plugin2', '--base-sha=master', '--run-on-changed-packages' ]); diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart index 22f00ea046c1..836d06671c24 100644 --- a/script/tool/test/list_command_test.dart +++ b/script/tool/test/list_command_test.dart @@ -139,7 +139,7 @@ void main() { ); }); - test('can filter plugins with the --plugins argument', () async { + test('can filter plugins with the --packages argument', () async { createFakePlugin('plugin1', packagesDir); // Create a federated plugin by creating a directory under the packages @@ -157,7 +157,7 @@ void main() { createFakePubspec(macLibrary); List plugins = await runCapturingPrint( - runner, ['list', '--plugins=plugin1']); + runner, ['list', '--packages=plugin1']); expect( plugins, unorderedEquals([ @@ -166,7 +166,7 @@ void main() { ); plugins = await runCapturingPrint( - runner, ['list', '--plugins=my_plugin']); + runner, ['list', '--packages=my_plugin']); expect( plugins, unorderedEquals([ @@ -177,7 +177,7 @@ void main() { ); plugins = await runCapturingPrint( - runner, ['list', '--plugins=my_plugin/my_plugin_web']); + runner, ['list', '--packages=my_plugin/my_plugin_web']); expect( plugins, unorderedEquals([ @@ -186,7 +186,7 @@ void main() { ); plugins = await runCapturingPrint(runner, - ['list', '--plugins=my_plugin/my_plugin_web,plugin1']); + ['list', '--packages=my_plugin/my_plugin_web,plugin1']); expect( plugins, unorderedEquals([ From 971d6c38e5cf2f4a2cfc3817d330ffec107d8439 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Sun, 4 Jul 2021 14:21:03 -0700 Subject: [PATCH 107/364] Prepare plugin repo for binding API improvements. (#4136) --- .../url_launcher/url_launcher/CHANGELOG.md | 5 ++++ .../url_launcher/lib/url_launcher.dart | 26 ++++++++++++++----- .../url_launcher/url_launcher/pubspec.yaml | 2 +- .../url_launcher/test/url_launcher_test.dart | 12 +++++++-- .../CHANGELOG.md | 5 ++++ .../lib/link.dart | 14 +++++++--- .../pubspec.yaml | 2 +- 7 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 1e7104c453e2..1dcf7a1582a8 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.0.9 + +* Silenced warnings that may occur during build when using a very + recent version of Flutter relating to null safety. + ## 6.0.8 * Adding API level 30 required package visibility configuration to the example's AndroidManifest.xml and README diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index e8d9670ec6d4..8c46520a71c4 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -84,10 +84,13 @@ Future launch( bool previousAutomaticSystemUiAdjustment = true; if (statusBarBrightness != null && defaultTargetPlatform == TargetPlatform.iOS && - WidgetsBinding.instance != null) { - previousAutomaticSystemUiAdjustment = - WidgetsBinding.instance!.renderView.automaticSystemUiAdjustment; - WidgetsBinding.instance!.renderView.automaticSystemUiAdjustment = false; + _ambiguate(WidgetsBinding.instance) != null) { + previousAutomaticSystemUiAdjustment = _ambiguate(WidgetsBinding.instance)! + .renderView + .automaticSystemUiAdjustment; + _ambiguate(WidgetsBinding.instance)! + .renderView + .automaticSystemUiAdjustment = false; SystemChrome.setSystemUIOverlayStyle(statusBarBrightness == Brightness.light ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light); @@ -104,9 +107,11 @@ Future launch( webOnlyWindowName: webOnlyWindowName, ); - if (statusBarBrightness != null && WidgetsBinding.instance != null) { - WidgetsBinding.instance!.renderView.automaticSystemUiAdjustment = - previousAutomaticSystemUiAdjustment; + if (statusBarBrightness != null && + _ambiguate(WidgetsBinding.instance) != null) { + _ambiguate(WidgetsBinding.instance)! + .renderView + .automaticSystemUiAdjustment = previousAutomaticSystemUiAdjustment; } return result; @@ -139,3 +144,10 @@ Future canLaunch(String urlString) async { Future closeWebView() async { return await UrlLauncherPlatform.instance.closeWebView(); } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 28dc71c56346..f6294ab30cc5 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.8 +version: 6.0.9 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher/test/url_launcher_test.dart b/packages/url_launcher/url_launcher/test/url_launcher_test.dart index 9b2d167483cd..04f727a57746 100644 --- a/packages/url_launcher/url_launcher/test/url_launcher_test.dart +++ b/packages/url_launcher/url_launcher/test/url_launcher_test.dart @@ -239,7 +239,7 @@ void main() { ..setResponse(true); final TestWidgetsFlutterBinding binding = - TestWidgetsFlutterBinding.ensureInitialized() + _anonymize(TestWidgetsFlutterBinding.ensureInitialized()) as TestWidgetsFlutterBinding; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; binding.renderView.automaticSystemUiAdjustment = true; @@ -268,7 +268,7 @@ void main() { ..setResponse(true); final TestWidgetsFlutterBinding binding = - TestWidgetsFlutterBinding.ensureInitialized() + _anonymize(TestWidgetsFlutterBinding.ensureInitialized()) as TestWidgetsFlutterBinding; debugDefaultTargetPlatformOverride = TargetPlatform.android; expect(binding.renderView.automaticSystemUiAdjustment, true); @@ -283,3 +283,11 @@ void main() { }); }); } + +/// This removes the type information from a value so that it can be cast +/// to another type even if that cast is redundant. +/// +/// We use this so that APIs whose type have become more descriptive can still +/// be used on the stable branch where they require a cast. +// TODO(ianh): Remove this once we roll stable in late 2021. +Object? _anonymize(T? value) => value; diff --git a/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md b/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md index 06a2efecc500..fc56473533f2 100644 --- a/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_platform_interface/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.0.4 + +* Silenced warnings that may occur during build when using a very + recent version of Flutter relating to null safety. + ## 2.0.3 * Migrate `pushRouteNameToFramework` to use ChannelBuffers API. diff --git a/packages/url_launcher/url_launcher_platform_interface/lib/link.dart b/packages/url_launcher/url_launcher_platform_interface/lib/link.dart index 4a414ae78f1f..ffff14feb8d7 100644 --- a/packages/url_launcher/url_launcher_platform_interface/lib/link.dart +++ b/packages/url_launcher/url_launcher_platform_interface/lib/link.dart @@ -87,9 +87,10 @@ typedef _SendMessage = Function(String, ByteData?, void Function(ByteData?)); Future pushRouteNameToFramework(Object? _, String routeName) { final Completer completer = Completer(); SystemNavigator.routeInformationUpdated(location: routeName); - final _SendMessage sendMessage = - WidgetsBinding.instance?.platformDispatcher.onPlatformMessage ?? - ui.channelBuffers.push; + final _SendMessage sendMessage = _ambiguate(WidgetsBinding.instance) + ?.platformDispatcher + .onPlatformMessage ?? + ui.channelBuffers.push; sendMessage( 'flutter/navigation', _codec.encodeMethodCall( @@ -102,3 +103,10 @@ Future pushRouteNameToFramework(Object? _, String routeName) { ); return completer.future; } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml b/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml index 9bb30c60f405..074e95b08c2c 100644 --- a/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml +++ b/packages/url_launcher/url_launcher_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.0.3 +version: 2.0.4 environment: sdk: ">=2.12.0 <3.0.0" From 1626b1060dc4c7dd9993da1c611a81221b0c29a0 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 7 Jul 2021 08:30:06 -0700 Subject: [PATCH 108/364] [video_player] Update to ExoPlayer 2.14.1 (#4141) Update the ExoPlayer dependency to the latest version, and remove the dependency on the deprecated Bintray server. This should resolve issues with building video_player due to 403s from bintray that have been breaking presubmits for all PRs. --- .../video_player/video_player/CHANGELOG.md | 4 ++++ .../video_player/android/build.gradle | 14 ++++--------- .../plugins/videoplayer/VideoPlayer.java | 20 ++++++++----------- .../video_player/video_player/pubspec.yaml | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 5084021b33de..f35a198a472d 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.7 + +* Update exoplayer to 2.14.1, removing dependency on Bintray. + ## 2.1.6 * Remove obsolete pre-1.0 warning from README. diff --git a/packages/video_player/video_player/android/build.gradle b/packages/video_player/video_player/android/build.gradle index 558b4123be11..d0ee30375376 100644 --- a/packages/video_player/video_player/android/build.gradle +++ b/packages/video_player/video_player/android/build.gradle @@ -17,12 +17,6 @@ rootProject.allprojects { repositories { google() mavenCentral() - // Gradle versions older than 2.13.3 aren't published to the servers - // above, so add this URL as a workaround until upgrading past that - // version. - maven { - url 'https://google.bintray.com/exoplayer/' - } } } @@ -50,10 +44,10 @@ android { } dependencies { - implementation 'com.google.android.exoplayer:exoplayer-core:2.12.1' - implementation 'com.google.android.exoplayer:exoplayer-hls:2.12.1' - implementation 'com.google.android.exoplayer:exoplayer-dash:2.12.1' - implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.12.1' + implementation 'com.google.android.exoplayer:exoplayer-core:2.14.1' + implementation 'com.google.android.exoplayer:exoplayer-hls:2.14.1' + implementation 'com.google.android.exoplayer:exoplayer-dash:2.14.1' + implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.14.1' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-inline:3.9.0' } diff --git a/packages/video_player/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java b/packages/video_player/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java index 87784eebdefe..887d3d15f175 100644 --- a/packages/video_player/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java +++ b/packages/video_player/video_player/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java @@ -16,7 +16,7 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Player.EventListener; +import com.google.android.exoplayer2.Player.Listener; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.source.MediaSource; @@ -29,7 +29,6 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.util.Util; import io.flutter.plugin.common.EventChannel; import io.flutter.view.TextureRegistry; @@ -77,15 +76,13 @@ final class VideoPlayer { DataSource.Factory dataSourceFactory; if (isHTTP(uri)) { - DefaultHttpDataSourceFactory httpDataSourceFactory = - new DefaultHttpDataSourceFactory( - "ExoPlayer", - null, - DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, - DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, - true); + DefaultHttpDataSource.Factory httpDataSourceFactory = + new DefaultHttpDataSource.Factory() + .setUserAgent("ExoPlayer") + .setAllowCrossProtocolRedirects(true); + if (httpHeaders != null && !httpHeaders.isEmpty()) { - httpDataSourceFactory.getDefaultRequestProperties().set(httpHeaders); + httpDataSourceFactory.setDefaultRequestProperties(httpHeaders); } dataSourceFactory = httpDataSourceFactory; } else { @@ -157,7 +154,6 @@ private MediaSource buildMediaSource( private void setupVideoPlayer( EventChannel eventChannel, TextureRegistry.SurfaceTextureEntry textureEntry) { - eventChannel.setStreamHandler( new EventChannel.StreamHandler() { @Override @@ -176,7 +172,7 @@ public void onCancel(Object o) { setAudioAttributes(exoPlayer, options.mixWithOthers); exoPlayer.addListener( - new EventListener() { + new Listener() { private boolean isBuffering = false; public void setBuffering(boolean buffering) { diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index ed78213cbc2b..15b2cc9d0963 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.6 +version: 2.1.7 environment: sdk: ">=2.12.0 <3.0.0" From b16697ca8837f2cb092cda892231389d6292bfc5 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 7 Jul 2021 08:32:03 -0700 Subject: [PATCH 109/364] [flutter_plugin_tools] Migrate 'test' to new base command (#4133) Migrates `test` to the new package looping base command. Refactors the bulk of the logic to helpers for easier understanding of the flow. Part of flutter/flutter#83413 --- script/tool/lib/src/test_command.dart | 123 ++++++++++++------------ script/tool/test/test_command_test.dart | 11 ++- 2 files changed, 66 insertions(+), 68 deletions(-) diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart index b7bf261caa8a..d06a2841812a 100644 --- a/script/tool/lib/src/test_command.dart +++ b/script/tool/lib/src/test_command.dart @@ -3,15 +3,14 @@ // found in the LICENSE file. import 'package:file/file.dart'; -import 'package:path/path.dart' as p; import 'common/core.dart'; -import 'common/plugin_command.dart'; +import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; /// A command to run Dart unit tests for packages. -class TestCommand extends PluginCommand { +class TestCommand extends PackageLoopingCommand { /// Creates an instance of the test command. TestCommand( Directory packagesDir, { @@ -20,7 +19,10 @@ class TestCommand extends PluginCommand { argParser.addOption( kEnableExperiment, defaultsTo: '', - help: 'Runs the tests in Dart VM with the given experiments enabled.', + help: + 'Runs Dart unit tests in Dart VM with the given experiments enabled. ' + 'See https://github.com/dart-lang/sdk/blob/master/docs/process/experimental-flags.md ' + 'for details.', ); } @@ -32,72 +34,65 @@ class TestCommand extends PluginCommand { 'This command requires "flutter" to be in your path.'; @override - Future run() async { - final List failingPackages = []; - await for (final Directory packageDir in getPackages()) { - final String packageName = - p.relative(packageDir.path, from: packagesDir.path); - if (!packageDir.childDirectory('test').existsSync()) { - print('SKIPPING $packageName - no test subdirectory'); - continue; - } + Future runForPackage(Directory package) async { + if (!package.childDirectory('test').existsSync()) { + return PackageResult.skip('No test/ directory.'); + } - print('RUNNING $packageName tests...'); + bool passed; + if (isFlutterPackage(package)) { + passed = await _runFlutterTests(package); + } else { + passed = await _runDartTests(package); + } + return passed ? PackageResult.success() : PackageResult.fail(); + } - final String enableExperiment = getStringArg(kEnableExperiment); + /// Runs the Dart tests for a Flutter package, returning true on success. + Future _runFlutterTests(Directory package) async { + final String experiment = getStringArg(kEnableExperiment); - // `flutter test` automatically gets packages. `pub run test` does not. :( - int exitCode = 0; - if (isFlutterPackage(packageDir)) { - final List args = [ - 'test', - '--color', - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - ]; + final int exitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'test', + '--color', + if (experiment.isNotEmpty) '--enable-experiment=$experiment', + // TODO(ditman): Remove this once all plugins are migrated to 'drive'. + if (isWebPlugin(package)) '--platform=chrome', + ], + workingDir: package, + ); + return exitCode == 0; + } - if (isWebPlugin(packageDir)) { - args.add('--platform=chrome'); - } - exitCode = await processRunner.runAndStream( - 'flutter', - args, - workingDir: packageDir, - ); - } else { - exitCode = await processRunner.runAndStream( - 'dart', - ['pub', 'get'], - workingDir: packageDir, - ); - if (exitCode == 0) { - exitCode = await processRunner.runAndStream( - 'dart', - [ - 'pub', - 'run', - if (enableExperiment.isNotEmpty) - '--enable-experiment=$enableExperiment', - 'test', - ], - workingDir: packageDir, - ); - } - } - if (exitCode != 0) { - failingPackages.add(packageName); - } + /// Runs the Dart tests for a non-Flutter package, returning true on success. + Future _runDartTests(Directory package) async { + // Unlike `flutter test`, `pub run test` does not automatically get + // packages + int exitCode = await processRunner.runAndStream( + 'dart', + ['pub', 'get'], + workingDir: package, + ); + if (exitCode != 0) { + printError('Unable to fetch dependencies.'); + return false; } - print('\n\n'); - if (failingPackages.isNotEmpty) { - print('Tests for the following packages are failing (see above):'); - for (final String package in failingPackages) { - print(' * $package'); - } - throw ToolExit(1); - } + final String experiment = getStringArg(kEnableExperiment); + + exitCode = await processRunner.runAndStream( + 'dart', + [ + 'pub', + 'run', + if (experiment.isNotEmpty) '--enable-experiment=$experiment', + 'test', + ], + workingDir: package, + ); - print('All tests are passing!'); + return exitCode == 0; } } diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 831910aad1fb..ac0ac4b3dd40 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -73,8 +73,8 @@ void main() { expect( output, containsAllInOrder([ - contains('Tests for the following packages are failing'), - contains(' * plugin1'), + contains('The following packages had errors:'), + contains(' plugin1'), ])); }); @@ -137,7 +137,9 @@ void main() { expect( output, containsAllInOrder([ - contains('Tests for the following packages are failing'), + contains('Unable to fetch dependencies'), + contains('The following packages had errors:'), + contains(' a_package'), ])); }); @@ -160,7 +162,8 @@ void main() { expect( output, containsAllInOrder([ - contains('Tests for the following packages are failing'), + contains('The following packages had errors:'), + contains(' a_package'), ])); }); From 3f940429d0e73e2cadfa8a3d487354e0e06f2206 Mon Sep 17 00:00:00 2001 From: nathanaelneveux Date: Wed, 7 Jul 2021 20:54:49 -0400 Subject: [PATCH 110/364] [video_player] Fixed HLS Streams on iOS (#3360) Refactor `FLTCMTimeToMillis` to support indefinite streams. Co-authored-by: Mike Diarmid --- packages/video_player/video_player/CHANGELOG.md | 4 ++++ .../integration_test/video_player_test.dart | 17 +++++++++++++++++ .../ios/Classes/FLTVideoPlayerPlugin.m | 15 ++++++++++----- packages/video_player/video_player/pubspec.yaml | 2 +- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index f35a198a472d..135057b403eb 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.8 + +* Refactor `FLTCMTimeToMillis` to support indefinite streams. Fixes [#48670](https://github.com/flutter/flutter/issues/48670). + ## 2.1.7 * Update exoplayer to 2.14.1, removing dependency on Bintray. diff --git a/packages/video_player/video_player/example/integration_test/video_player_test.dart b/packages/video_player/video_player/example/integration_test/video_player_test.dart index 87575df3cf09..aef3beb1a10e 100644 --- a/packages/video_player/video_player/example/integration_test/video_player_test.dart +++ b/packages/video_player/video_player/example/integration_test/video_player_test.dart @@ -77,6 +77,23 @@ void main() { skip: !(kIsWeb || defaultTargetPlatform == TargetPlatform.android), ); + testWidgets( + 'live stream duration != 0', + (WidgetTester tester) async { + VideoPlayerController networkController = VideoPlayerController.network( + 'https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8', + ); + await networkController.initialize(); + + expect(networkController.value.isInitialized, true); + // Live streams should have either a positive duration or C.TIME_UNSET if the duration is unknown + // See https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/Player.html#getDuration-- + expect(networkController.value.duration, + (Duration duration) => duration != Duration.zero); + }, + skip: (kIsWeb), + ); + testWidgets( 'can be played', (WidgetTester tester) async { diff --git a/packages/video_player/video_player/ios/Classes/FLTVideoPlayerPlugin.m b/packages/video_player/video_player/ios/Classes/FLTVideoPlayerPlugin.m index b359c1b6c898..f0f672d87431 100644 --- a/packages/video_player/video_player/ios/Classes/FLTVideoPlayerPlugin.m +++ b/packages/video_player/video_player/ios/Classes/FLTVideoPlayerPlugin.m @@ -11,11 +11,6 @@ #error Code Requires ARC. #endif -int64_t FLTCMTimeToMillis(CMTime time) { - if (time.timescale == 0) return 0; - return time.value * 1000 / time.timescale; -} - @interface FLTFrameUpdater : NSObject @property(nonatomic) int64_t textureId; @property(nonatomic, weak, readonly) NSObject* registry; @@ -107,6 +102,16 @@ - (void)itemDidPlayToEndTime:(NSNotification*)notification { } } +const int64_t TIME_UNSET = -9223372036854775807; + +static inline int64_t FLTCMTimeToMillis(CMTime time) { + // When CMTIME_IS_INDEFINITE return a value that matches TIME_UNSET from ExoPlayer2 on Android. + // Fixes https://github.com/flutter/flutter/issues/48670 + if (CMTIME_IS_INDEFINITE(time)) return TIME_UNSET; + if (time.timescale == 0) return 0; + return time.value * 1000 / time.timescale; +} + static inline CGFloat radiansToDegrees(CGFloat radians) { // Input range [-pi, pi] or [-180, 180] CGFloat degrees = GLKMathRadiansToDegrees((float)radians); diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 15b2cc9d0963..cf0312916b01 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.7 +version: 2.1.8 environment: sdk: ">=2.12.0 <3.0.0" From 6dd1c4658b500ee569caeb131872a805289d2d26 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 7 Jul 2021 20:02:35 -0700 Subject: [PATCH 111/364] [flutter_plugin_tools] Work around banner in drive-examples (#4142) * [flutter_plugin_tools] Work around banner in drive-examples Strip off any unexpected output before parsing `flutter devices --machine` output. Works around a bug in the Flutter tool that can result in banners being printed in `--machine` mode. Fixes https://github.com/flutter/flutter/issues/86052 --- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/drive_examples_command.dart | 9 ++++- .../test/drive_examples_command_test.dart | 37 ++++++++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 9e9538ce554f..3f31a4953f6b 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -9,6 +9,7 @@ immediately abort the test. - Deprecated `--plugins` in favor of new `--packages`. `--plugins` continues to work for now, but will be removed in the future. +- Make `drive-examples` device detection robust against Flutter tool banners. ## 0.3.0 diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 1e19535f4a25..df74119e4019 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -208,9 +208,14 @@ class DriveExamplesCommand extends PackageLoopingCommand { return deviceIds; } + String output = result.stdout as String; + // --machine doesn't currently prevent the tool from printing banners; + // see https://github.com/flutter/flutter/issues/86055. This workaround + // can be removed once that is fixed. + output = output.substring(output.indexOf('[')); + final List> devices = - (jsonDecode(result.stdout as String) as List) - .cast>(); + (jsonDecode(output) as List).cast>(); for (final Map deviceInfo in devices) { final String targetPlatform = (deviceInfo['targetPlatform'] as String?) ?? ''; diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index d22d95f14d57..681a9e0e5844 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -44,13 +44,22 @@ void main() { void setMockFlutterDevicesOutput({ bool hasIosDevice = true, bool hasAndroidDevice = true, + bool includeBanner = false, }) { + const String updateBanner = ''' +╔════════════════════════════════════════════════════════════════════════════╗ +║ A new version of Flutter is available! ║ +║ ║ +║ To update to the latest version, run "flutter upgrade". ║ +╚════════════════════════════════════════════════════════════════════════════╝ +'''; final List devices = [ if (hasIosDevice) '{"id": "$_fakeIosDevice", "targetPlatform": "ios"}', if (hasAndroidDevice) '{"id": "$_fakeAndroidDevice", "targetPlatform": "android-x86"}', ]; - final String output = '''[${devices.join(',')}]'''; + final String output = + '''${includeBanner ? updateBanner : ''}[${devices.join(',')}]'''; final MockProcess mockDevicesProcess = MockProcess.succeeding(); mockDevicesProcess.stdoutController.close(); // ignore: unawaited_futures @@ -113,6 +122,32 @@ void main() { ); }); + test('handles flutter tool banners when checking devices', () async { + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/integration_test.dart', + 'example/integration_test/foo_test.dart', + ], + platformSupport: { + kPlatformIos: PlatformSupport.inline, + }, + ); + + setMockFlutterDevicesOutput(includeBanner: true); + final List output = + await runCapturingPrint(runner, ['drive-examples', '--ios']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No issues found!'), + ]), + ); + }); + test('fails for iOS if getting devices fails', () async { // Simulate failure from `flutter devices`. processRunner.mockProcessesForExecutable['flutter'] = [ From d0ac2f7c16bcd6f987f61377f7e1f7aeb3b209d9 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Wed, 7 Jul 2021 22:31:04 -0700 Subject: [PATCH 112/364] [various] Prepare plugin repo for binding API improvements (#4137) --- .../test/method_channel_battery_test.dart | 20 +++++++--- packages/camera/camera/example/lib/main.dart | 9 ++++- .../method_channel_connectivity_test.dart | 20 +++++++--- packages/sensors/test/sensors_test.dart | 22 +++++++--- .../video_player/video_player/CHANGELOG.md | 5 +++ .../video_player/lib/video_player.dart | 11 ++++- .../video_player/video_player/pubspec.yaml | 2 +- .../method_channel_video_player_test.dart | 26 +++++++++--- .../test/webview_flutter_test.dart | 40 +++++++++++++------ 9 files changed, 114 insertions(+), 41 deletions(-) diff --git a/packages/battery/battery_platform_interface/test/method_channel_battery_test.dart b/packages/battery/battery_platform_interface/test/method_channel_battery_test.dart index 6a312ee73ef2..697582843a95 100644 --- a/packages/battery/battery_platform_interface/test/method_channel_battery_test.dart +++ b/packages/battery/battery_platform_interface/test/method_channel_battery_test.dart @@ -32,13 +32,14 @@ void main() { .setMockMethodCallHandler((MethodCall methodCall) async { switch (methodCall.method) { case 'listen': - await ServicesBinding.instance!.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger .handlePlatformMessage( - methodChannelBattery.eventChannel.name, - methodChannelBattery.eventChannel.codec - .encodeSuccessEnvelope('full'), - (_) {}, - ); + methodChannelBattery.eventChannel.name, + methodChannelBattery.eventChannel.codec + .encodeSuccessEnvelope('full'), + (_) {}, + ); break; case 'cancel': default: @@ -61,3 +62,10 @@ void main() { }); }); } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 536e95de9c8b..16d585db9308 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -68,7 +68,7 @@ class _CameraExampleHomeState extends State @override void initState() { super.initState(); - WidgetsBinding.instance?.addObserver(this); + _ambiguate(WidgetsBinding.instance)?.addObserver(this); _flashModeControlRowAnimationController = AnimationController( duration: const Duration(milliseconds: 300), @@ -951,3 +951,10 @@ Future main() async { } runApp(CameraApp()); } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/connectivity/connectivity_platform_interface/test/method_channel_connectivity_test.dart b/packages/connectivity/connectivity_platform_interface/test/method_channel_connectivity_test.dart index 38f4ac38b156..b69feae252eb 100644 --- a/packages/connectivity/connectivity_platform_interface/test/method_channel_connectivity_test.dart +++ b/packages/connectivity/connectivity_platform_interface/test/method_channel_connectivity_test.dart @@ -42,13 +42,14 @@ void main() { .setMockMethodCallHandler((MethodCall methodCall) async { switch (methodCall.method) { case 'listen': - await ServicesBinding.instance!.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger .handlePlatformMessage( - methodChannelConnectivity.eventChannel.name, - methodChannelConnectivity.eventChannel.codec - .encodeSuccessEnvelope('wifi'), - (_) {}, - ); + methodChannelConnectivity.eventChannel.name, + methodChannelConnectivity.eventChannel.codec + .encodeSuccessEnvelope('wifi'), + (_) {}, + ); break; case 'cancel': default: @@ -151,3 +152,10 @@ void main() { }); }); } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/sensors/test/sensors_test.dart b/packages/sensors/test/sensors_test.dart index 659c658c3604..bce3afe6205b 100644 --- a/packages/sensors/test/sensors_test.dart +++ b/packages/sensors/test/sensors_test.dart @@ -52,14 +52,17 @@ void _initializeFakeSensorChannel(String channelName, List sensorData) { const StandardMethodCodec standardMethod = StandardMethodCodec(); void _emitEvent(ByteData? event) { - ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - channelName, - event, - (ByteData? reply) {}, - ); + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage( + channelName, + event, + (ByteData? reply) {}, + ); } - ServicesBinding.instance!.defaultBinaryMessenger + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger .setMockMessageHandler(channelName, (ByteData? message) async { final MethodCall methodCall = standardMethod.decodeMethodCall(message); if (methodCall.method == 'listen') { @@ -73,3 +76,10 @@ void _initializeFakeSensorChannel(String channelName, List sensorData) { } }); } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 135057b403eb..b082d1b66980 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.1.9 + +* Silenced warnings that may occur during build when using a very + recent version of Flutter relating to null safety. + ## 2.1.8 * Refactor `FLTCMTimeToMillis` to support indefinite streams. Fixes [#48670](https://github.com/flutter/flutter/issues/48670). diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index d5bd7d2f222d..1708d49d678b 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -573,7 +573,7 @@ class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver { final VideoPlayerController _controller; void initialize() { - WidgetsBinding.instance!.addObserver(this); + _ambiguate(WidgetsBinding.instance)!.addObserver(this); } @override @@ -593,7 +593,7 @@ class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver { } void dispose() { - WidgetsBinding.instance!.removeObserver(this); + _ambiguate(WidgetsBinding.instance)!.removeObserver(this); } } @@ -949,3 +949,10 @@ class ClosedCaption extends StatelessWidget { ); } } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index cf0312916b01..c24377fc1b8b 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.8 +version: 2.1.9 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart b/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart index 33a5b34b615d..9da71617e66a 100644 --- a/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart +++ b/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart @@ -232,13 +232,16 @@ void main() { }); test('videoEventsFor', () async { - ServicesBinding.instance?.defaultBinaryMessenger.setMockMessageHandler( + _ambiguate(ServicesBinding.instance) + ?.defaultBinaryMessenger + .setMockMessageHandler( "flutter.io/videoPlayer/videoEvents123", (ByteData? message) async { final MethodCall methodCall = const StandardMethodCodec().decodeMethodCall(message); if (methodCall.method == 'listen') { - await ServicesBinding.instance?.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance) + ?.defaultBinaryMessenger .handlePlatformMessage( "flutter.io/videoPlayer/videoEvents123", const StandardMethodCodec() @@ -250,7 +253,8 @@ void main() { }), (ByteData? data) {}); - await ServicesBinding.instance?.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance) + ?.defaultBinaryMessenger .handlePlatformMessage( "flutter.io/videoPlayer/videoEvents123", const StandardMethodCodec() @@ -259,7 +263,8 @@ void main() { }), (ByteData? data) {}); - await ServicesBinding.instance?.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance) + ?.defaultBinaryMessenger .handlePlatformMessage( "flutter.io/videoPlayer/videoEvents123", const StandardMethodCodec() @@ -272,7 +277,8 @@ void main() { }), (ByteData? data) {}); - await ServicesBinding.instance?.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance) + ?.defaultBinaryMessenger .handlePlatformMessage( "flutter.io/videoPlayer/videoEvents123", const StandardMethodCodec() @@ -281,7 +287,8 @@ void main() { }), (ByteData? data) {}); - await ServicesBinding.instance?.defaultBinaryMessenger + await _ambiguate(ServicesBinding.instance) + ?.defaultBinaryMessenger .handlePlatformMessage( "flutter.io/videoPlayer/videoEvents123", const StandardMethodCodec() @@ -325,3 +332,10 @@ void main() { }); }); } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/test/webview_flutter_test.dart index 4360484408b5..5efee6d9952d 100644 --- a/packages/webview_flutter/test/webview_flutter_test.dart +++ b/packages/webview_flutter/test/webview_flutter_test.dart @@ -1019,7 +1019,8 @@ class FakePlatformWebView { }; final ByteData data = codec .encodeMethodCall(MethodCall('javascriptChannelMessage', arguments)); - ServicesBinding.instance!.defaultBinaryMessenger + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger .handlePlatformMessage(channel.name, data, (ByteData? data) {}); } @@ -1038,7 +1039,8 @@ class FakePlatformWebView { }; final ByteData data = codec.encodeMethodCall(MethodCall('navigationRequest', arguments)); - ServicesBinding.instance!.defaultBinaryMessenger + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger .handlePlatformMessage(channel.name, data, (ByteData? data) { final bool allow = codec.decodeEnvelope(data!); if (allow) { @@ -1055,11 +1057,13 @@ class FakePlatformWebView { {'url': currentUrl}, )); - ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - channel.name, - data, - (ByteData? data) {}, - ); + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + data, + (ByteData? data) {}, + ); } void fakeOnPageFinishedCallback() { @@ -1070,11 +1074,13 @@ class FakePlatformWebView { {'url': currentUrl}, )); - ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - channel.name, - data, - (ByteData? data) {}, - ); + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + data, + (ByteData? data) {}, + ); } void fakeOnProgressCallback(int progress) { @@ -1085,7 +1091,8 @@ class FakePlatformWebView { {'progress': progress}, )); - ServicesBinding.instance!.defaultBinaryMessenger + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger .handlePlatformMessage(channel.name, data, (ByteData? data) {}); } @@ -1244,3 +1251,10 @@ class MatchesCreationParams extends Matcher { .matches(creationParams.javascriptChannelNames, matchState); } } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; From 744730cd972fb8162dda13a82addf2a0b47b0228 Mon Sep 17 00:00:00 2001 From: Ryo Kamimura Date: Thu, 8 Jul 2021 16:01:06 +0900 Subject: [PATCH 113/364] [plugin_platform_interface] Fix README broken link (#4143) --- packages/plugin_platform_interface/CHANGELOG.md | 4 ++++ packages/plugin_platform_interface/README.md | 2 +- packages/plugin_platform_interface/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/plugin_platform_interface/CHANGELOG.md b/packages/plugin_platform_interface/CHANGELOG.md index cea8f2a76266..987049c55996 100644 --- a/packages/plugin_platform_interface/CHANGELOG.md +++ b/packages/plugin_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.1 + +* Fix `federated flutter plugins` link in the README.md. + ## 2.0.0 * Migrate to null safety. diff --git a/packages/plugin_platform_interface/README.md b/packages/plugin_platform_interface/README.md index 9fdbd8a75fe2..2fe44328c7dc 100644 --- a/packages/plugin_platform_interface/README.md +++ b/packages/plugin_platform_interface/README.md @@ -1,6 +1,6 @@ # plugin_platform_interface -This package provides a base class for platform interfaces of [federated flutter plugins](https://fluter.dev/go/federated-plugins). +This package provides a base class for platform interfaces of [federated flutter plugins](https://flutter.dev/go/federated-plugins). Platform implementations should extend their platform interface classes rather than implement it as newly added methods to platform interfaces are not considered as breaking changes. Extending a platform diff --git a/packages/plugin_platform_interface/pubspec.yaml b/packages/plugin_platform_interface/pubspec.yaml index a59a81481fe2..2980a62ee998 100644 --- a/packages/plugin_platform_interface/pubspec.yaml +++ b/packages/plugin_platform_interface/pubspec.yaml @@ -14,7 +14,7 @@ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+ # be done when absolutely necessary and after the ecosystem has already migrated to 1.X.Y version # that is forward compatible with 2.0.0 (ideally the ecosystem have migrated to depend on: # `plugin_platform_interface: >=1.X.Y <3.0.0`). -version: 2.0.0 +version: 2.0.1 environment: sdk: ">=2.12.0 <3.0.0" From e2541ca01c3d6f41c6dae8faed8d1f635073cd68 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Thu, 8 Jul 2021 11:56:10 +0200 Subject: [PATCH 114/364] [camera] android-rework part 8: Supporting modules for final implementation (#4054) --- .../plugins/camera/CameraCaptureCallback.java | 168 ++++++++ .../plugins/camera/CameraProperties.java | 4 +- .../flutter/plugins/camera/CameraState.java | 27 ++ .../flutter/plugins/camera/DartMessenger.java | 51 ++- .../io/flutter/plugins/camera/ImageSaver.java | 105 +++++ .../camera/features/CameraFeatureFactory.java | 141 +++++++ .../features/CameraFeatureFactoryImpl.java | 95 +++++ .../camera/features/CameraFeatures.java | 248 ++++++++++++ .../camera/types/CaptureTimeoutsWrapper.java | 52 +++ .../flutter/plugins/camera/types/Timeout.java | 51 +++ .../CameraCaptureCallbackStatesTest.java | 377 ++++++++++++++++++ .../plugins/camera/ImageSaverTests.java | 105 +++++ 12 files changed, 1418 insertions(+), 6 deletions(-) create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java new file mode 100644 index 000000000000..21dcb602655d --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java @@ -0,0 +1,168 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCaptureSession.CaptureCallback; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.CaptureResult; +import android.hardware.camera2.TotalCaptureResult; +import android.util.Log; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; + +/** + * A callback object for tracking the progress of a {@link android.hardware.camera2.CaptureRequest} + * submitted to the camera device. + */ +class CameraCaptureCallback extends CaptureCallback { + private static final String TAG = "CameraCaptureCallback"; + private final CameraCaptureStateListener cameraStateListener; + private CameraState cameraState; + private final CaptureTimeoutsWrapper captureTimeouts; + + private CameraCaptureCallback( + @NonNull CameraCaptureStateListener cameraStateListener, + @NonNull CaptureTimeoutsWrapper captureTimeouts) { + cameraState = CameraState.STATE_PREVIEW; + this.cameraStateListener = cameraStateListener; + this.captureTimeouts = captureTimeouts; + } + + /** + * Creates a new instance of the {@link CameraCaptureCallback} class. + * + * @param cameraStateListener instance which will be called when the camera state changes. + * @param captureTimeouts specifying the different timeout counters that should be taken into + * account. + * @return a configured instance of the {@link CameraCaptureCallback} class. + */ + public static CameraCaptureCallback create( + @NonNull CameraCaptureStateListener cameraStateListener, + @NonNull CaptureTimeoutsWrapper captureTimeouts) { + return new CameraCaptureCallback(cameraStateListener, captureTimeouts); + } + + /** + * Gets the current {@link CameraState}. + * + * @return the current {@link CameraState}. + */ + public CameraState getCameraState() { + return cameraState; + } + + /** + * Sets the {@link CameraState}. + * + * @param state the camera is currently in. + */ + public void setCameraState(@NonNull CameraState state) { + cameraState = state; + } + + private void process(CaptureResult result) { + Integer aeState = result.get(CaptureResult.CONTROL_AE_STATE); + Integer afState = result.get(CaptureResult.CONTROL_AF_STATE); + + if (cameraState != CameraState.STATE_PREVIEW) { + Log.d( + TAG, + "CameraCaptureCallback | state: " + + cameraState + + " | afState: " + + afState + + " | aeState: " + + aeState); + } + + switch (cameraState) { + case STATE_PREVIEW: + { + // We have nothing to do when the camera preview is working normally. + break; + } + case STATE_WAITING_FOCUS: + { + if (afState == null) { + return; + } else if (afState == CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED + || afState == CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED) { + handleWaitingFocusState(aeState); + } else if (captureTimeouts.getPreCaptureFocusing().getIsExpired()) { + Log.w(TAG, "Focus timeout, moving on with capture"); + handleWaitingFocusState(aeState); + } + + break; + } + case STATE_WAITING_PRECAPTURE_START: + { + // CONTROL_AE_STATE can be null on some devices + if (aeState == null + || aeState == CaptureResult.CONTROL_AE_STATE_CONVERGED + || aeState == CaptureResult.CONTROL_AE_STATE_PRECAPTURE + || aeState == CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED) { + setCameraState(CameraState.STATE_WAITING_PRECAPTURE_DONE); + } else if (captureTimeouts.getPreCaptureMetering().getIsExpired()) { + Log.w(TAG, "Metering timeout waiting for pre-capture to start, moving on with capture"); + + setCameraState(CameraState.STATE_WAITING_PRECAPTURE_DONE); + } + break; + } + case STATE_WAITING_PRECAPTURE_DONE: + { + // CONTROL_AE_STATE can be null on some devices + if (aeState == null || aeState != CaptureResult.CONTROL_AE_STATE_PRECAPTURE) { + cameraStateListener.onConverged(); + } else if (captureTimeouts.getPreCaptureMetering().getIsExpired()) { + Log.w( + TAG, "Metering timeout waiting for pre-capture to finish, moving on with capture"); + cameraStateListener.onConverged(); + } + + break; + } + } + } + + private void handleWaitingFocusState(Integer aeState) { + // CONTROL_AE_STATE can be null on some devices + if (aeState == null || aeState == CaptureRequest.CONTROL_AE_STATE_CONVERGED) { + cameraStateListener.onConverged(); + } else { + cameraStateListener.onPrecapture(); + } + } + + @Override + public void onCaptureProgressed( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull CaptureResult partialResult) { + process(partialResult); + } + + @Override + public void onCaptureCompleted( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull TotalCaptureResult result) { + process(result); + } + + /** An interface that describes the different state changes implementers can be informed about. */ + interface CameraCaptureStateListener { + + /** Called when the {@link android.hardware.camera2.CaptureRequest} has been converged. */ + void onConverged(); + + /** + * Called when the {@link android.hardware.camera2.CaptureRequest} enters the pre-capture state. + */ + void onPrecapture(); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraProperties.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraProperties.java index a69ddd0410d4..95efebbf6488 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraProperties.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraProperties.java @@ -124,7 +124,7 @@ public interface CameraProperties { *

  • @see android.hardware.camera2.CameraMetadata.LENS_FACING_EXTERNAL * * - * By default maps to the @see android.hardware.camera2.CameraCharacteristics.LENS_FACING key. + *

    By default maps to the @see android.hardware.camera2.CameraCharacteristics.LENS_FACING key. * * @return int Direction the camera faces relative to device screen. */ @@ -216,7 +216,7 @@ public interface CameraProperties { *

  • @see android.hardware.camera2.CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL * * - * By default maps to the @see + *

    By default maps to the @see * android.hardware.camera2.CameraCharacteristics#INFO_SUPPORTED_HARDWARE_LEVEL key. * * @return int Level which generally classifies the overall set of the camera device diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java new file mode 100644 index 000000000000..ac48caf18ac6 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java @@ -0,0 +1,27 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +/** + * These are the states that the camera can be in. The camera can only take one photo at a time so + * this state describes the state of the camera itself. The camera works like a pipeline where we + * feed it requests through. It can only process one tasks at a time. + */ +public enum CameraState { + /** Idle, showing preview and not capturing anything. */ + STATE_PREVIEW, + + /** Starting and waiting for autofocus to complete. */ + STATE_WAITING_FOCUS, + + /** Start performing autoexposure. */ + STATE_WAITING_PRECAPTURE_START, + + /** waiting for autoexposure to complete. */ + STATE_WAITING_PRECAPTURE_DONE, + + /** Capturing an image. */ + STATE_CAPTURING, +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java index 37bfbf294663..93b963e65821 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java @@ -16,12 +16,15 @@ import java.util.HashMap; import java.util.Map; +/** Utility class that facilitates communication to the Flutter client */ public class DartMessenger { @NonNull private final Handler handler; @Nullable private MethodChannel cameraChannel; @Nullable private MethodChannel deviceChannel; + /** Specifies the different device related message types. */ enum DeviceEventType { + /** Indicates the device's orientation has changed. */ ORIENTATION_CHANGED("orientation_changed"); private final String method; @@ -30,24 +33,47 @@ enum DeviceEventType { } } + /** Specifies the different camera related message types. */ enum CameraEventType { + /** Indicates that an error occurred while interacting with the camera. */ ERROR("error"), + /** Indicates that the camera is closing. */ CLOSING("camera_closing"), + /** Indicates that the camera is initialized. */ INITIALIZED("initialized"); private final String method; + /** + * Converts the supplied method name to the matching {@link CameraEventType}. + * + * @param method name to be converted into a {@link CameraEventType}. + */ CameraEventType(String method) { this.method = method; } } + /** + * Creates a new instance of the {@link DartMessenger} class. + * + * @param messenger is the {@link BinaryMessenger} that is used to communicate with Flutter. + * @param cameraId identifies the camera which is the source of the communication. + * @param handler the handler used to manage the thread's message queue. This should always be a + * handler managing the main thread since communication with Flutter should always happen on + * the main thread. The handler is mainly supplied so it will be easier test this class. + */ DartMessenger(BinaryMessenger messenger, long cameraId, @NonNull Handler handler) { cameraChannel = new MethodChannel(messenger, "flutter.io/cameraPlugin/camera" + cameraId); deviceChannel = new MethodChannel(messenger, "flutter.io/cameraPlugin/device"); this.handler = handler; } + /** + * Sends a message to the Flutter client informing the orientation of the device has been changed. + * + * @param orientation specifies the new orientation of the device. + */ public void sendDeviceOrientationChangeEvent(PlatformChannel.DeviceOrientation orientation) { assert (orientation != null); this.send( @@ -59,6 +85,16 @@ public void sendDeviceOrientationChangeEvent(PlatformChannel.DeviceOrientation o }); } + /** + * Sends a message to the Flutter client informing that the camera has been initialized. + * + * @param previewWidth describes the preview width that is supported by the camera. + * @param previewHeight describes the preview height that is supported by the camera. + * @param exposureMode describes the current exposure mode that is set on the camera. + * @param focusMode describes the current focus mode that is set on the camera. + * @param exposurePointSupported indicates if the camera supports setting an exposure point. + * @param focusPointSupported indicates if the camera supports setting a focus point. + */ void sendCameraInitializedEvent( Integer previewWidth, Integer previewHeight, @@ -86,10 +122,17 @@ void sendCameraInitializedEvent( }); } + /** Sends a message to the Flutter client informing that the camera is closing. */ void sendCameraClosingEvent() { send(CameraEventType.CLOSING); } + /** + * Sends a message to the Flutter client informing that an error occurred while interacting with + * the camera. + * + * @param description contains details regarding the error that occurred. + */ void sendCameraErrorEvent(@Nullable String description) { this.send( CameraEventType.ERROR, @@ -100,11 +143,11 @@ void sendCameraErrorEvent(@Nullable String description) { }); } - void send(CameraEventType eventType) { + private void send(CameraEventType eventType) { send(eventType, new HashMap<>()); } - void send(CameraEventType eventType, Map args) { + private void send(CameraEventType eventType, Map args) { if (cameraChannel == null) { return; } @@ -118,11 +161,11 @@ public void run() { }); } - void send(DeviceEventType eventType) { + private void send(DeviceEventType eventType) { send(eventType, new HashMap<>()); } - void send(DeviceEventType eventType, Map args) { + private void send(DeviceEventType eventType, Map args) { if (deviceChannel == null) { return; } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java new file mode 100644 index 000000000000..821c9a50c13f --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java @@ -0,0 +1,105 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import android.media.Image; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** Saves a JPEG {@link Image} into the specified {@link File}. */ +public class ImageSaver implements Runnable { + + /** The JPEG image */ + private final Image image; + + /** The file we save the image into. */ + private final File file; + + /** Used to report the status of the save action. */ + private final Callback callback; + + /** + * Creates an instance of the ImageSaver runnable + * + * @param image - The image to save + * @param file - The file to save the image to + * @param callback - The callback that is run on completion, or when an error is encountered. + */ + ImageSaver(@NonNull Image image, @NonNull File file, @NonNull Callback callback) { + this.image = image; + this.file = file; + this.callback = callback; + } + + @Override + public void run() { + ByteBuffer buffer = image.getPlanes()[0].getBuffer(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + FileOutputStream output = null; + try { + output = FileOutputStreamFactory.create(file); + output.write(bytes); + + callback.onComplete(file.getAbsolutePath()); + + } catch (IOException e) { + callback.onError("IOError", "Failed saving image"); + } finally { + image.close(); + if (null != output) { + try { + output.close(); + } catch (IOException e) { + callback.onError("cameraAccess", e.getMessage()); + } + } + } + } + + /** + * The interface for the callback that is passed to ImageSaver, for detecting completion or + * failure of the image saving task. + */ + public interface Callback { + /** + * Called when the image file has been saved successfully. + * + * @param absolutePath - The absolute path of the file that was saved. + */ + void onComplete(String absolutePath); + + /** + * Called when an error is encountered while saving the image file. + * + * @param errorCode - The error code. + * @param errorMessage - The human readable error message. + */ + void onError(String errorCode, String errorMessage); + } + + /** Factory class that assists in creating a {@link FileOutputStream} instance. */ + static class FileOutputStreamFactory { + /** + * Creates a new instance of the {@link FileOutputStream} class. + * + *

    This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param file - The file to create the output stream for + * @return new instance of the {@link FileOutputStream} class. + * @throws FileNotFoundException when the supplied file could not be found. + */ + @VisibleForTesting + public static FileOutputStream create(File file) throws FileNotFoundException { + return new FileOutputStream(file); + } + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java new file mode 100644 index 000000000000..8d10c445788c --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java @@ -0,0 +1,141 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; + +/** + * Factory for creating the supported feature implementation controlling different aspects of the + * {@link android.hardware.camera2.CaptureRequest}. + */ +public interface CameraFeatureFactory { + + /** + * Creates a new instance of the auto focus feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @param recordingVideo indicates if the camera is currently recording. + * @return newly created instance of the AutoFocusFeature class. + */ + AutoFocusFeature createAutoFocusFeature( + @NonNull CameraProperties cameraProperties, boolean recordingVideo); + + /** + * Creates a new instance of the exposure lock feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ExposureLockFeature class. + */ + ExposureLockFeature createExposureLockFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the exposure offset feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ExposureOffsetFeature class. + */ + ExposureOffsetFeature createExposureOffsetFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the flash feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the FlashFeature class. + */ + FlashFeature createFlashFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the resolution feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @param initialSetting initial resolution preset. + * @param cameraName the name of the camera which can be used to identify the camera device. + * @return newly created instance of the ResolutionFeature class. + */ + ResolutionFeature createResolutionFeature( + @NonNull CameraProperties cameraProperties, + ResolutionPreset initialSetting, + String cameraName); + + /** + * Creates a new instance of the focus point feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the FocusPointFeature class. + */ + FocusPointFeature createFocusPointFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the FPS range feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the FpsRangeFeature class. + */ + FpsRangeFeature createFpsRangeFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the sensor orientation feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @param activity current activity associated with the camera plugin. + * @param dartMessenger instance of the DartMessenger class, used to send state updates back to + * Dart. + * @return newly created instance of the SensorOrientationFeature class. + */ + SensorOrientationFeature createSensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger); + + /** + * Creates a new instance of the zoom level feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ZoomLevelFeature class. + */ + ZoomLevelFeature createZoomLevelFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the exposure point feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ExposurePointFeature class. + */ + ExposurePointFeature createExposurePointFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the noise reduction feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the NoiseReductionFeature class. + */ + NoiseReductionFeature createNoiseReductionFeature(@NonNull CameraProperties cameraProperties); +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java new file mode 100644 index 000000000000..b12ad3626226 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java @@ -0,0 +1,95 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; + +/** + * Implementation of the {@link CameraFeatureFactory} interface creating the supported feature + * implementation controlling different aspects of the {@link + * android.hardware.camera2.CaptureRequest}. + */ +public class CameraFeatureFactoryImpl implements CameraFeatureFactory { + + @Override + public AutoFocusFeature createAutoFocusFeature( + @NonNull CameraProperties cameraProperties, boolean recordingVideo) { + return new AutoFocusFeature(cameraProperties, recordingVideo); + } + + @Override + public ExposureLockFeature createExposureLockFeature(@NonNull CameraProperties cameraProperties) { + return new ExposureLockFeature(cameraProperties); + } + + @Override + public ExposureOffsetFeature createExposureOffsetFeature( + @NonNull CameraProperties cameraProperties) { + return new ExposureOffsetFeature(cameraProperties); + } + + @Override + public FlashFeature createFlashFeature(@NonNull CameraProperties cameraProperties) { + return new FlashFeature(cameraProperties); + } + + @Override + public ResolutionFeature createResolutionFeature( + @NonNull CameraProperties cameraProperties, + ResolutionPreset initialSetting, + String cameraName) { + return new ResolutionFeature(cameraProperties, initialSetting, cameraName); + } + + @Override + public FocusPointFeature createFocusPointFeature(@NonNull CameraProperties cameraProperties) { + return new FocusPointFeature(cameraProperties); + } + + @Override + public FpsRangeFeature createFpsRangeFeature(@NonNull CameraProperties cameraProperties) { + return new FpsRangeFeature(cameraProperties); + } + + @Override + public SensorOrientationFeature createSensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger) { + return new SensorOrientationFeature(cameraProperties, activity, dartMessenger); + } + + @Override + public ZoomLevelFeature createZoomLevelFeature(@NonNull CameraProperties cameraProperties) { + return new ZoomLevelFeature(cameraProperties); + } + + @Override + public ExposurePointFeature createExposurePointFeature( + @NonNull CameraProperties cameraProperties) { + return new ExposurePointFeature(cameraProperties); + } + + @Override + public NoiseReductionFeature createNoiseReductionFeature( + @NonNull CameraProperties cameraProperties) { + return new NoiseReductionFeature(cameraProperties); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java new file mode 100644 index 000000000000..0ee8969071bc --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java @@ -0,0 +1,248 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.features; + +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * These are all of our available features in the camera. Used in the Camera to access all features + * in a simpler way. + */ +public class CameraFeatures { + private static final String AUTO_FOCUS = "AUTO_FOCUS"; + private static final String EXPOSURE_LOCK = "EXPOSURE_LOCK"; + private static final String EXPOSURE_OFFSET = "EXPOSURE_OFFSET"; + private static final String EXPOSURE_POINT = "EXPOSURE_POINT"; + private static final String FLASH = "FLASH"; + private static final String FOCUS_POINT = "FOCUS_POINT"; + private static final String FPS_RANGE = "FPS_RANGE"; + private static final String NOISE_REDUCTION = "NOISE_REDUCTION"; + private static final String REGION_BOUNDARIES = "REGION_BOUNDARIES"; + private static final String RESOLUTION = "RESOLUTION"; + private static final String SENSOR_ORIENTATION = "SENSOR_ORIENTATION"; + private static final String ZOOM_LEVEL = "ZOOM_LEVEL"; + + private Map featureMap = new HashMap<>(); + + /** + * Gets a collection of all features that have been set. + * + * @return A collection of all features that have been set. + */ + public Collection getAllFeatures() { + return this.featureMap.values(); + } + + /** + * Gets the auto focus feature if it has been set. + * + * @return the auto focus feature. + */ + public AutoFocusFeature getAutoFocus() { + return (AutoFocusFeature) featureMap.get(AUTO_FOCUS); + } + + /** + * Sets the instance of the auto focus feature. + * + * @param autoFocus the {@link AutoFocusFeature} instance to set. + */ + public void setAutoFocus(AutoFocusFeature autoFocus) { + this.featureMap.put(AUTO_FOCUS, autoFocus); + } + + /** + * Gets the exposure lock feature if it has been set. + * + * @return the exposure lock feature. + */ + public ExposureLockFeature getExposureLock() { + return (ExposureLockFeature) featureMap.get(EXPOSURE_LOCK); + } + + /** + * Sets the instance of the exposure lock feature. + * + * @param exposureLock the {@link ExposureLockFeature} instance to set. + */ + public void setExposureLock(ExposureLockFeature exposureLock) { + this.featureMap.put(EXPOSURE_LOCK, exposureLock); + } + + /** + * Gets the exposure offset feature if it has been set. + * + * @return the exposure offset feature. + */ + public ExposureOffsetFeature getExposureOffset() { + return (ExposureOffsetFeature) featureMap.get(EXPOSURE_OFFSET); + } + + /** + * Sets the instance of the exposure offset feature. + * + * @param exposureOffset the {@link ExposureOffsetFeature} instance to set. + */ + public void setExposureOffset(ExposureOffsetFeature exposureOffset) { + this.featureMap.put(EXPOSURE_OFFSET, exposureOffset); + } + + /** + * Gets the exposure point feature if it has been set. + * + * @return the exposure point feature. + */ + public ExposurePointFeature getExposurePoint() { + return (ExposurePointFeature) featureMap.get(EXPOSURE_POINT); + } + + /** + * Sets the instance of the exposure point feature. + * + * @param exposurePoint the {@link ExposurePointFeature} instance to set. + */ + public void setExposurePoint(ExposurePointFeature exposurePoint) { + this.featureMap.put(EXPOSURE_POINT, exposurePoint); + } + + /** + * Gets the flash feature if it has been set. + * + * @return the flash feature. + */ + public FlashFeature getFlash() { + return (FlashFeature) featureMap.get(FLASH); + } + + /** + * Sets the instance of the flash feature. + * + * @param flash the {@link FlashFeature} instance to set. + */ + public void setFlash(FlashFeature flash) { + this.featureMap.put(FLASH, flash); + } + + /** + * Gets the focus point feature if it has been set. + * + * @return the focus point feature. + */ + public FocusPointFeature getFocusPoint() { + return (FocusPointFeature) featureMap.get(FOCUS_POINT); + } + + /** + * Sets the instance of the focus point feature. + * + * @param focusPoint the {@link FocusPointFeature} instance to set. + */ + public void setFocusPoint(FocusPointFeature focusPoint) { + this.featureMap.put(FOCUS_POINT, focusPoint); + } + + /** + * Gets the fps range feature if it has been set. + * + * @return the fps range feature. + */ + public FpsRangeFeature getFpsRange() { + return (FpsRangeFeature) featureMap.get(FPS_RANGE); + } + + /** + * Sets the instance of the fps range feature. + * + * @param fpsRange the {@link FpsRangeFeature} instance to set. + */ + public void setFpsRange(FpsRangeFeature fpsRange) { + this.featureMap.put(FPS_RANGE, fpsRange); + } + + /** + * Gets the noise reduction feature if it has been set. + * + * @return the noise reduction feature. + */ + public NoiseReductionFeature getNoiseReduction() { + return (NoiseReductionFeature) featureMap.get(NOISE_REDUCTION); + } + + /** + * Sets the instance of the noise reduction feature. + * + * @param noiseReduction the {@link NoiseReductionFeature} instance to set. + */ + public void setNoiseReduction(NoiseReductionFeature noiseReduction) { + this.featureMap.put(NOISE_REDUCTION, noiseReduction); + } + + /** + * Gets the resolution feature if it has been set. + * + * @return the resolution feature. + */ + public ResolutionFeature getResolution() { + return (ResolutionFeature) featureMap.get(RESOLUTION); + } + + /** + * Sets the instance of the resolution feature. + * + * @param resolution the {@link ResolutionFeature} instance to set. + */ + public void setResolution(ResolutionFeature resolution) { + this.featureMap.put(RESOLUTION, resolution); + } + + /** + * Gets the sensor orientation feature if it has been set. + * + * @return the sensor orientation feature. + */ + public SensorOrientationFeature getSensorOrientation() { + return (SensorOrientationFeature) featureMap.get(SENSOR_ORIENTATION); + } + + /** + * Sets the instance of the sensor orientation feature. + * + * @param sensorOrientation the {@link SensorOrientationFeature} instance to set. + */ + public void setSensorOrientation(SensorOrientationFeature sensorOrientation) { + this.featureMap.put(SENSOR_ORIENTATION, sensorOrientation); + } + + /** + * Gets the zoom level feature if it has been set. + * + * @return the zoom level feature. + */ + public ZoomLevelFeature getZoomLevel() { + return (ZoomLevelFeature) featureMap.get(ZOOM_LEVEL); + } + + /** + * Sets the instance of the zoom level feature. + * + * @param zoomLevel the {@link ZoomLevelFeature} instance to set. + */ + public void setZoomLevel(ZoomLevelFeature zoomLevel) { + this.featureMap.put(ZOOM_LEVEL, zoomLevel); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java new file mode 100644 index 000000000000..ad59bd09c754 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java @@ -0,0 +1,52 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.types; + +/** + * Wrapper class that provides a container for all {@link Timeout} instances that are required for + * the capture flow. + */ +public class CaptureTimeoutsWrapper { + private Timeout preCaptureFocusing; + private Timeout preCaptureMetering; + private final long preCaptureFocusingTimeoutMs; + private final long preCaptureMeteringTimeoutMs; + + /** + * Create a new wrapper instance with the specified timeout values. + * + * @param preCaptureFocusingTimeoutMs focusing timeout milliseconds. + * @param preCaptureMeteringTimeoutMs metering timeout milliseconds. + */ + public CaptureTimeoutsWrapper( + long preCaptureFocusingTimeoutMs, long preCaptureMeteringTimeoutMs) { + this.preCaptureFocusingTimeoutMs = preCaptureFocusingTimeoutMs; + this.preCaptureMeteringTimeoutMs = preCaptureMeteringTimeoutMs; + } + + /** Reset all timeouts to the current timestamp. */ + public void reset() { + this.preCaptureFocusing = Timeout.create(preCaptureFocusingTimeoutMs); + this.preCaptureMetering = Timeout.create(preCaptureMeteringTimeoutMs); + } + + /** + * Returns the timeout instance related to precapture focusing. + * + * @return - The timeout object + */ + public Timeout getPreCaptureFocusing() { + return preCaptureFocusing; + } + + /** + * Returns the timeout instance related to precapture metering. + * + * @return - The timeout object + */ + public Timeout getPreCaptureMetering() { + return preCaptureMetering; + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java new file mode 100644 index 000000000000..67e05499d47a --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java @@ -0,0 +1,51 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.types; + +import android.os.SystemClock; + +/** + * This is a simple class for managing a timeout. In the camera we generally keep two timeouts: one + * for focusing and one for pre-capture metering. + * + *

    We use timeouts to ensure a picture is always captured within a reasonable amount of time even + * if the settings don't converge and focus can't be locked. + * + *

    You generally check the status of the timeout in the CameraCaptureCallback during the capture + * sequence and use it to move to the next state if the timeout has passed. + */ +public class Timeout { + + /** The timeout time in milliseconds */ + private final long timeoutMs; + + /** When this timeout was started. Will be used later to check if the timeout has expired yet. */ + private final long timeStarted; + + /** + * Factory method to create a new Timeout. + * + * @param timeoutMs timeout to use. + * @return returns a new Timeout. + */ + public static Timeout create(long timeoutMs) { + return new Timeout(timeoutMs); + } + + /** + * Create a new timeout. + * + * @param timeoutMs the time in milliseconds for this timeout to lapse. + */ + private Timeout(long timeoutMs) { + this.timeoutMs = timeoutMs; + this.timeStarted = SystemClock.elapsedRealtime(); + } + + /** Will return true when the timeout period has lapsed. */ + public boolean getIsExpired() { + return (SystemClock.elapsedRealtime() - timeStarted) > timeoutMs; + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java new file mode 100644 index 000000000000..4964aef8b8c9 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java @@ -0,0 +1,377 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.CaptureResult; +import android.hardware.camera2.CaptureResult.Key; +import android.hardware.camera2.TotalCaptureResult; +import io.flutter.plugins.camera.CameraCaptureCallback.CameraCaptureStateListener; +import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; +import io.flutter.plugins.camera.types.Timeout; +import io.flutter.plugins.camera.utils.TestUtils; +import java.util.HashMap; +import java.util.Map; +import junit.framework.TestCase; +import junit.framework.TestSuite; +import org.mockito.MockedStatic; + +public class CameraCaptureCallbackStatesTest extends TestCase { + private final Integer aeState; + private final Integer afState; + private final CameraState cameraState; + private final boolean isTimedOut; + + private Runnable validate; + + private CameraCaptureCallback cameraCaptureCallback; + private CameraCaptureStateListener mockCaptureStateListener; + private CameraCaptureSession mockCameraCaptureSession; + private CaptureRequest mockCaptureRequest; + private CaptureResult mockPartialCaptureResult; + private CaptureTimeoutsWrapper mockCaptureTimeouts; + private TotalCaptureResult mockTotalCaptureResult; + private MockedStatic mockedStaticTimeout; + private Timeout mockTimeout; + + public static TestSuite suite() { + TestSuite suite = new TestSuite(); + + setUpPreviewStateTest(suite); + setUpWaitingFocusTests(suite); + setUpWaitingPreCaptureStartTests(suite); + setUpWaitingPreCaptureDoneTests(suite); + + return suite; + } + + protected CameraCaptureCallbackStatesTest( + String name, CameraState cameraState, Integer afState, Integer aeState) { + this(name, cameraState, afState, aeState, false); + } + + protected CameraCaptureCallbackStatesTest( + String name, CameraState cameraState, Integer afState, Integer aeState, boolean isTimedOut) { + super(name); + + this.aeState = aeState; + this.afState = afState; + this.cameraState = cameraState; + this.isTimedOut = isTimedOut; + } + + @Override + @SuppressWarnings("unchecked") + protected void setUp() throws Exception { + super.setUp(); + + mockedStaticTimeout = mockStatic(Timeout.class); + mockCaptureStateListener = mock(CameraCaptureStateListener.class); + mockCameraCaptureSession = mock(CameraCaptureSession.class); + mockCaptureRequest = mock(CaptureRequest.class); + mockPartialCaptureResult = mock(CaptureResult.class); + mockTotalCaptureResult = mock(TotalCaptureResult.class); + mockTimeout = mock(Timeout.class); + mockCaptureTimeouts = mock(CaptureTimeoutsWrapper.class); + when(mockCaptureTimeouts.getPreCaptureFocusing()).thenReturn(mockTimeout); + when(mockCaptureTimeouts.getPreCaptureMetering()).thenReturn(mockTimeout); + + Key mockAeStateKey = mock(Key.class); + Key mockAfStateKey = mock(Key.class); + + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AE_STATE", mockAeStateKey); + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AF_STATE", mockAfStateKey); + + mockedStaticTimeout.when(() -> Timeout.create(1000)).thenReturn(mockTimeout); + + cameraCaptureCallback = + CameraCaptureCallback.create(mockCaptureStateListener, mockCaptureTimeouts); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + + mockedStaticTimeout.close(); + + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AE_STATE", null); + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AF_STATE", null); + } + + @Override + protected void runTest() throws Throwable { + when(mockPartialCaptureResult.get(CaptureResult.CONTROL_AF_STATE)).thenReturn(afState); + when(mockPartialCaptureResult.get(CaptureResult.CONTROL_AE_STATE)).thenReturn(aeState); + when(mockTotalCaptureResult.get(CaptureResult.CONTROL_AF_STATE)).thenReturn(afState); + when(mockTotalCaptureResult.get(CaptureResult.CONTROL_AE_STATE)).thenReturn(aeState); + + cameraCaptureCallback.setCameraState(cameraState); + if (isTimedOut) { + when(mockTimeout.getIsExpired()).thenReturn(true); + cameraCaptureCallback.onCaptureCompleted( + mockCameraCaptureSession, mockCaptureRequest, mockTotalCaptureResult); + } else { + cameraCaptureCallback.onCaptureProgressed( + mockCameraCaptureSession, mockCaptureRequest, mockPartialCaptureResult); + } + + validate.run(); + } + + private static void setUpPreviewStateTest(TestSuite suite) { + CameraCaptureCallbackStatesTest previewStateTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_or_pre_capture_when_state_is_preview", + CameraState.STATE_PREVIEW, + null, + null); + previewStateTest.validate = + () -> { + verify(previewStateTest.mockCaptureStateListener, never()).onConverged(); + verify(previewStateTest.mockCaptureStateListener, never()).onConverged(); + assertEquals( + CameraState.STATE_PREVIEW, previewStateTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(previewStateTest); + } + + private static void setUpWaitingFocusTests(TestSuite suite) { + Integer[] actionableAfStates = + new Integer[] { + CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED, + CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED + }; + + Integer[] nonActionableAfStates = + new Integer[] { + CaptureResult.CONTROL_AF_STATE_ACTIVE_SCAN, + CaptureResult.CONTROL_AF_STATE_INACTIVE, + CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED, + CaptureResult.CONTROL_AF_STATE_PASSIVE_SCAN, + CaptureResult.CONTROL_AF_STATE_PASSIVE_UNFOCUSED + }; + + Map aeStatesConvergeMap = + new HashMap() { + { + put(null, true); + put(CaptureResult.CONTROL_AE_STATE_CONVERGED, true); + put(CaptureResult.CONTROL_AE_STATE_PRECAPTURE, false); + put(CaptureResult.CONTROL_AE_STATE_LOCKED, false); + put(CaptureResult.CONTROL_AE_STATE_SEARCHING, false); + put(CaptureResult.CONTROL_AE_STATE_INACTIVE, false); + put(CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED, false); + } + }; + + CameraCaptureCallbackStatesTest nullStateTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_or_pre_capture_when_afstate_is_null", + CameraState.STATE_WAITING_FOCUS, + null, + null); + nullStateTest.validate = + () -> { + verify(nullStateTest.mockCaptureStateListener, never()).onConverged(); + verify(nullStateTest.mockCaptureStateListener, never()).onConverged(); + assertEquals( + CameraState.STATE_WAITING_FOCUS, + nullStateTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(nullStateTest); + + for (Integer afState : actionableAfStates) { + aeStatesConvergeMap.forEach( + (aeState, shouldConverge) -> { + CameraCaptureCallbackStatesTest focusLockedTest = + new CameraCaptureCallbackStatesTest( + "process_should_converge_when_af_state_is_" + + afState + + "_and_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_FOCUS, + afState, + aeState); + focusLockedTest.validate = + () -> { + if (shouldConverge) { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onConverged(); + verify(focusLockedTest.mockCaptureStateListener, never()).onPrecapture(); + } else { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onPrecapture(); + verify(focusLockedTest.mockCaptureStateListener, never()).onConverged(); + } + assertEquals( + CameraState.STATE_WAITING_FOCUS, + focusLockedTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(focusLockedTest); + }); + } + + for (Integer afState : nonActionableAfStates) { + CameraCaptureCallbackStatesTest focusLockedTest = + new CameraCaptureCallbackStatesTest( + "process_should_do_nothing_when_af_state_is_" + afState, + CameraState.STATE_WAITING_FOCUS, + afState, + null); + focusLockedTest.validate = + () -> { + verify(focusLockedTest.mockCaptureStateListener, never()).onConverged(); + verify(focusLockedTest.mockCaptureStateListener, never()).onPrecapture(); + assertEquals( + CameraState.STATE_WAITING_FOCUS, + focusLockedTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(focusLockedTest); + } + + for (Integer afState : nonActionableAfStates) { + aeStatesConvergeMap.forEach( + (aeState, shouldConverge) -> { + CameraCaptureCallbackStatesTest focusLockedTest = + new CameraCaptureCallbackStatesTest( + "process_should_converge_when_af_state_is_" + + afState + + "_and_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_FOCUS, + afState, + aeState, + true); + focusLockedTest.validate = + () -> { + if (shouldConverge) { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onConverged(); + verify(focusLockedTest.mockCaptureStateListener, never()).onPrecapture(); + } else { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onPrecapture(); + verify(focusLockedTest.mockCaptureStateListener, never()).onConverged(); + } + assertEquals( + CameraState.STATE_WAITING_FOCUS, + focusLockedTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(focusLockedTest); + }); + } + } + + private static void setUpWaitingPreCaptureStartTests(TestSuite suite) { + Map cameraStateMap = + new HashMap() { + { + put(null, CameraState.STATE_WAITING_PRECAPTURE_DONE); + put( + CaptureResult.CONTROL_AE_STATE_INACTIVE, + CameraState.STATE_WAITING_PRECAPTURE_START); + put( + CaptureResult.CONTROL_AE_STATE_SEARCHING, + CameraState.STATE_WAITING_PRECAPTURE_START); + put( + CaptureResult.CONTROL_AE_STATE_CONVERGED, + CameraState.STATE_WAITING_PRECAPTURE_DONE); + put(CaptureResult.CONTROL_AE_STATE_LOCKED, CameraState.STATE_WAITING_PRECAPTURE_START); + put( + CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED, + CameraState.STATE_WAITING_PRECAPTURE_DONE); + put( + CaptureResult.CONTROL_AE_STATE_PRECAPTURE, + CameraState.STATE_WAITING_PRECAPTURE_DONE); + } + }; + + cameraStateMap.forEach( + (aeState, cameraState) -> { + CameraCaptureCallbackStatesTest testCase = + new CameraCaptureCallbackStatesTest( + "process_should_update_camera_state_to_waiting_pre_capture_done_when_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_PRECAPTURE_START, + null, + aeState); + testCase.validate = + () -> assertEquals(cameraState, testCase.cameraCaptureCallback.getCameraState()); + suite.addTest(testCase); + }); + + cameraStateMap.forEach( + (aeState, cameraState) -> { + if (cameraState == CameraState.STATE_WAITING_PRECAPTURE_DONE) { + return; + } + + CameraCaptureCallbackStatesTest testCase = + new CameraCaptureCallbackStatesTest( + "process_should_update_camera_state_to_waiting_pre_capture_done_when_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_PRECAPTURE_START, + null, + aeState, + true); + testCase.validate = + () -> + assertEquals( + CameraState.STATE_WAITING_PRECAPTURE_DONE, + testCase.cameraCaptureCallback.getCameraState()); + suite.addTest(testCase); + }); + } + + private static void setUpWaitingPreCaptureDoneTests(TestSuite suite) { + Integer[] onConvergeStates = + new Integer[] { + null, + CaptureResult.CONTROL_AE_STATE_CONVERGED, + CaptureResult.CONTROL_AE_STATE_LOCKED, + CaptureResult.CONTROL_AE_STATE_SEARCHING, + CaptureResult.CONTROL_AE_STATE_INACTIVE, + CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED, + }; + + for (Integer aeState : onConvergeStates) { + CameraCaptureCallbackStatesTest shouldConvergeTest = + new CameraCaptureCallbackStatesTest( + "process_should_converge_when_ae_state_is_" + aeState, + CameraState.STATE_WAITING_PRECAPTURE_DONE, + null, + null); + shouldConvergeTest.validate = + () -> verify(shouldConvergeTest.mockCaptureStateListener, times(1)).onConverged(); + suite.addTest(shouldConvergeTest); + } + + CameraCaptureCallbackStatesTest shouldNotConvergeTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_when_ae_state_is_pre_capture", + CameraState.STATE_WAITING_PRECAPTURE_DONE, + null, + CaptureResult.CONTROL_AE_STATE_PRECAPTURE); + shouldNotConvergeTest.validate = + () -> verify(shouldNotConvergeTest.mockCaptureStateListener, never()).onConverged(); + suite.addTest(shouldNotConvergeTest); + + CameraCaptureCallbackStatesTest shouldConvergeWhenTimedOutTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_when_ae_state_is_pre_capture", + CameraState.STATE_WAITING_PRECAPTURE_DONE, + null, + CaptureResult.CONTROL_AE_STATE_PRECAPTURE, + true); + shouldConvergeWhenTimedOutTest.validate = + () -> + verify(shouldConvergeWhenTimedOutTest.mockCaptureStateListener, times(1)).onConverged(); + suite.addTest(shouldConvergeWhenTimedOutTest); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java new file mode 100644 index 000000000000..d2c9f4498332 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java @@ -0,0 +1,105 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.media.Image; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +public class ImageSaverTests { + + Image mockImage; + File mockFile; + ImageSaver.Callback mockCallback; + ImageSaver imageSaver; + Image.Plane mockPlane; + ByteBuffer mockBuffer; + MockedStatic mockFileOutputStreamFactory; + FileOutputStream mockFileOutputStream; + + @Before + public void setup() { + // Set up mocked file dependency + mockFile = mock(File.class); + when(mockFile.getAbsolutePath()).thenReturn("absolute/path"); + mockPlane = mock(Image.Plane.class); + mockBuffer = mock(ByteBuffer.class); + when(mockBuffer.remaining()).thenReturn(3); + when(mockBuffer.get(any())) + .thenAnswer( + new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + byte[] bytes = invocation.getArgument(0); + bytes[0] = 0x42; + bytes[1] = 0x00; + bytes[2] = 0x13; + return mockBuffer; + } + }); + + // Set up mocked image dependency + mockImage = mock(Image.class); + when(mockPlane.getBuffer()).thenReturn(mockBuffer); + when(mockImage.getPlanes()).thenReturn(new Image.Plane[] {mockPlane}); + + // Set up mocked FileOutputStream + mockFileOutputStreamFactory = mockStatic(ImageSaver.FileOutputStreamFactory.class); + mockFileOutputStream = mock(FileOutputStream.class); + mockFileOutputStreamFactory + .when(() -> ImageSaver.FileOutputStreamFactory.create(any())) + .thenReturn(mockFileOutputStream); + + // Set up testable ImageSaver instance + mockCallback = mock(ImageSaver.Callback.class); + imageSaver = new ImageSaver(mockImage, mockFile, mockCallback); + } + + @After + public void teardown() { + mockFileOutputStreamFactory.close(); + } + + @Test + public void run_writes_bytes_to_file_and_finishes_with_path() throws IOException { + imageSaver.run(); + + verify(mockFileOutputStream, times(1)).write(new byte[] {0x42, 0x00, 0x13}); + verify(mockCallback, times(1)).onComplete("absolute/path"); + verify(mockCallback, never()).onError(any(), any()); + } + + @Test + public void run_calls_error_on_write_ioexception() throws IOException { + doThrow(new IOException()).when(mockFileOutputStream).write(any()); + imageSaver.run(); + verify(mockCallback, times(1)).onError("IOError", "Failed saving image"); + verify(mockCallback, never()).onComplete(any()); + } + + @Test + public void run_calls_error_on_close_ioexception() throws IOException { + doThrow(new IOException("message")).when(mockFileOutputStream).close(); + imageSaver.run(); + verify(mockCallback, times(1)).onError("cameraAccess", "message"); + } +} From fa036005b294e755f4c251e1b114f9212b4c1d21 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 8 Jul 2021 14:56:04 +0200 Subject: [PATCH 115/364] [in_app_purchase] Add documentation for price change confirmations (#4092) --- .../in_app_purchase/in_app_purchase/README.md | 101 ++++++++++++++++++ .../in_app_purchase/example/lib/main.dart | 64 ++++++++++- .../in_app_purchase/pubspec.yaml | 4 +- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index 28b3c0821cf3..61803e35ebdc 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -247,6 +247,107 @@ InAppPurchase.instance .buyNonConsumable(purchaseParam: purchaseParam); ``` +### Confirming subscription price changes + +When the price of a subscription is changed the consumer will need to confirm that price change. If the consumer does not +confirm the price change the subscription will not be auto-renewed. By default on both iOS and Android the consumer will +automatically get a popup to confirm the price change, but App developers can override this mechanism and show the popup on a later moment so it doesn't interrupt the critical flow of the App. This works different on the Apple App Store and on the Google Play Store. + +#### Google Play Store (Android) +When the subscription price is raised, the consumer should approve the price change within 7 days. The official +documentation can be found [here](https://support.google.com/googleplay/android-developer/answer/140504?hl=en#zippy=%2Cprice-changes). +When the price is lowered the consumer will automatically receive the lower price and does not have to approve the price change. + +After 7 days the consumer will be notified through email and notifications on Google Play to agree with the new price. App developers have 7 days to explain the consumer that the price is going to change and ask them to accept this change. App developers have to keep track of whether or not the price change is already accepted within the app or in the backend. The [Google Play API](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions) can be used to check whether or not the price change is accepted by the consumer by reading the `priceChange` property on a subscription object. + +The `InAppPurchaseAndroidPlatformAddition` can be used to show the price change confirmation flow. The additions contain the function `launchPriceChangeConfirmationFlow` which needs the SKU code of the subscription. + +```dart +//import for InAppPurchaseAndroidPlatformAddition +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +//import for BillingResponse +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; + +if (Platform.isAndroid) { + final InAppPurchaseAndroidPlatformAddition androidAddition = + _inAppPurchase + .getPlatformAddition(); + var priceChangeConfirmationResult = + await androidAddition.launchPriceChangeConfirmationFlow( + sku: 'purchaseId', + ); + if (priceChangeConfirmationResult.responseCode == BillingResponse.ok){ + // TODO acknowledge price change + }else{ + // TODO show error + } +} +``` + +#### Apple App Store (iOS) + +When the price of a subscription is raised iOS will also show a popup in the app. +The StoreKit Payment Queue will notify the app that it wants to show a price change confirmation popup. +By default the queue will get the response that it can continue and show the popup. +However, it is possible to prevent this popup via the InAppPurchaseIosPlatformAddition and show the +popup at a different time, for example after clicking a button. + +To know when the App Store wants to show a popup and prevent this from happening a queue delegate can be registered. +The `InAppPurchaseIosPlatformAddition` contains a `setDelegate(SKPaymentQueueDelegateWrapper? delegate)` function that +can be used to set a delegate or remove one by setting it to `null`. +```dart +//import for InAppPurchaseIosPlatformAddition +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; + +Future initStoreInfo() async { + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); + } +} + +@override +Future disposeStore() { + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iosPlatformAddition.setDelegate(null); + } +} +``` +The delegate that is set should implement `SKPaymentQueueDelegateWrapper` and handle `shouldContinueTransaction` and +`shouldShowPriceConsent`. When setting `shouldShowPriceConsent` to false the default popup will not be shown and the app +needs to show this later. + +```dart +// import for SKPaymentQueueDelegateWrapper +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} +``` + +The dialog can be shown by calling `showPriceConsentIfNeeded` on the `InAppPurchaseIosPlatformAddition`. This future +will complete immediately when the dialog is shown. A confirmed transaction will be delivered on the `purchaseStream`. +```dart +if (Platform.isIOS) { + var iapIosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iapIosPlatformAddition.showPriceConsentIfNeeded(); +} +``` + ### Accessing platform specific product or purchase properties The function `_inAppPurchase.queryProductDetails(productIds);` provides a `ProductDetailsResponse` with a diff --git a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart index 5429a00125ac..73ecadb3f15d 100644 --- a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; import 'consumable_store.dart'; void main() { @@ -84,6 +86,12 @@ class _MyAppState extends State<_MyApp> { return; } + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); + } + ProductDetailsResponse productDetailResponse = await _inAppPurchase.queryProductDetails(_kProductIds.toSet()); if (productDetailResponse.error != null) { @@ -127,6 +135,11 @@ class _MyAppState extends State<_MyApp> { @override void dispose() { + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + iosPlatformAddition.setDelegate(null); + } _subscription.cancel(); super.dispose(); } @@ -245,7 +258,9 @@ class _MyAppState extends State<_MyApp> { productDetails.description, ), trailing: previousPurchase != null - ? Icon(Icons.check) + ? IconButton( + onPressed: () => confirmPriceChange(context), + icon: Icon(Icons.upgrade)) : TextButton( child: Text(productDetails.price), style: TextButton.styleFrom( @@ -438,6 +453,35 @@ class _MyAppState extends State<_MyApp> { }); } + Future confirmPriceChange(BuildContext context) async { + if (Platform.isAndroid) { + final InAppPurchaseAndroidPlatformAddition androidAddition = + _inAppPurchase + .getPlatformAddition(); + var priceChangeConfirmationResult = + await androidAddition.launchPriceChangeConfirmationFlow( + sku: 'purchaseId', + ); + if (priceChangeConfirmationResult.responseCode == BillingResponse.ok) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Price change accepted'), + )); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + priceChangeConfirmationResult.debugMessage ?? + "Price change failed with code ${priceChangeConfirmationResult.responseCode}", + ), + )); + } + } + if (Platform.isIOS) { + var iapIosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iapIosPlatformAddition.showPriceConsentIfNeeded(); + } + } + GooglePlayPurchaseDetails? _getOldSubscription( ProductDetails productDetails, Map purchases) { // This is just to demonstrate a subscription upgrade or downgrade. @@ -460,3 +504,21 @@ class _MyAppState extends State<_MyApp> { return oldSubscription; } } + +/// Example implementation of the +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index b589c24d3677..554a07b0bd30 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -20,8 +20,8 @@ dependencies: flutter: sdk: flutter in_app_purchase_platform_interface: ^1.0.0 - in_app_purchase_android: ^0.1.0 - in_app_purchase_ios: ^0.1.0 + in_app_purchase_android: ^0.1.4 + in_app_purchase_ios: ^0.1.1 dev_dependencies: flutter_driver: From e1b4ba4228a86e471193c065d087cb689c5c22ca Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 8 Jul 2021 15:26:05 +0200 Subject: [PATCH 116/364] [in_app_purchase] Add currencySymbol to ProductDetails (#4115) --- .../in_app_purchase_platform_interface/CHANGELOG.md | 4 ++++ .../lib/src/types/product_details.dart | 6 ++++++ .../in_app_purchase_platform_interface/pubspec.yaml | 2 +- .../test/src/types/product_details_test.dart | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md index 15978f3756ef..ec619d2fdc37 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.0 + +* Added `currencySymbol` in ProductDetails. + ## 1.0.1 * Fixed `Restoring previous purchases` link. diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details.dart index 2d82d04ae71e..aa03a41b4776 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details.dart @@ -12,6 +12,7 @@ class ProductDetails { required this.price, required this.rawPrice, required this.currencyCode, + this.currencySymbol = '', }); /// The identifier of the product. @@ -42,4 +43,9 @@ class ProductDetails { /// The currency code for the price of the product. /// Based on the price specified in the App Store Connect or Sku in Google Play console based on the platform. final String currencyCode; + + /// The currency symbol for the locale, e.g. $ for US locale. + /// + /// When the currency symbol cannot be determined, the ISO 4217 currency code is returned. + final String currencySymbol; } diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml index a5be5a005e2c..d15e5f40fc6f 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purch issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 1.0.1 +version: 1.1.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/types/product_details_test.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/types/product_details_test.dart index d6cbce04c64a..ce49d9992131 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/types/product_details_test.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/types/product_details_test.dart @@ -16,6 +16,7 @@ void main() { description: 'description', price: '13.37', currencyCode: 'USD', + currencySymbol: r'$', rawPrice: 13.37); expect(productDetails.id, 'id'); @@ -23,6 +24,7 @@ void main() { expect(productDetails.description, 'description'); expect(productDetails.rawPrice, 13.37); expect(productDetails.currencyCode, 'USD'); + expect(productDetails.currencySymbol, r'$'); }); }); } From 0b88ffb57d1481d9535aa1a46498ff3f4b2953d8 Mon Sep 17 00:00:00 2001 From: Kyle Finlinson <5882840+KyleFin@users.noreply.github.com> Date: Thu, 8 Jul 2021 10:31:04 -0600 Subject: [PATCH 117/364] [video_player] Pause video when it completes (#3727) --- .../video_player/video_player/CHANGELOG.md | 4 ++ .../integration_test/video_player_test.dart | 45 +++++++++++++++++++ .../video_player/lib/video_player.dart | 12 ++++- .../video_player/video_player/pubspec.yaml | 2 +- .../video_player/test/video_player_test.dart | 21 ++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index b082d1b66980..b9f029b31454 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.10 + +* Ensure video pauses correctly when it finishes. + ## 2.1.9 * Silenced warnings that may occur during build when using a very diff --git a/packages/video_player/video_player/example/integration_test/video_player_test.dart b/packages/video_player/video_player/example/integration_test/video_player_test.dart index aef3beb1a10e..6821b26e0409 100644 --- a/packages/video_player/video_player/example/integration_test/video_player_test.dart +++ b/packages/video_player/video_player/example/integration_test/video_player_test.dart @@ -143,6 +143,51 @@ void main() { }, ); + testWidgets( + 'stay paused when seeking after video completed', + (WidgetTester tester) async { + await _controller.initialize(); + // Mute to allow playing without DOM interaction on Web. + // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes + await _controller.setVolume(0); + Duration tenMillisBeforeEnd = + _controller.value.duration - const Duration(milliseconds: 10); + await _controller.seekTo(tenMillisBeforeEnd); + await _controller.play(); + await tester.pumpAndSettle(_playDuration); + expect(_controller.value.isPlaying, false); + expect(_controller.value.position, _controller.value.duration); + + await _controller.seekTo(tenMillisBeforeEnd); + await tester.pumpAndSettle(_playDuration); + + expect(_controller.value.isPlaying, false); + expect(_controller.value.position, tenMillisBeforeEnd); + }, + ); + + testWidgets( + 'do not exceed duration on play after video completed', + (WidgetTester tester) async { + await _controller.initialize(); + // Mute to allow playing without DOM interaction on Web. + // See https://developers.google.com/web/updates/2017/09/autoplay-policy-changes + await _controller.setVolume(0); + await _controller.seekTo( + _controller.value.duration - const Duration(milliseconds: 10)); + await _controller.play(); + await tester.pumpAndSettle(_playDuration); + expect(_controller.value.isPlaying, false); + expect(_controller.value.position, _controller.value.duration); + + await _controller.play(); + await tester.pumpAndSettle(_playDuration); + + expect(_controller.value.position, + lessThanOrEqualTo(_controller.value.duration)); + }, + ); + testWidgets('test video player view with local asset', (WidgetTester tester) async { Future started() async { diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 1708d49d678b..772409258ac4 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -328,8 +328,11 @@ class VideoPlayerController extends ValueNotifier { _applyPlayPause(); break; case VideoEventType.completed: - value = value.copyWith(isPlaying: false, position: value.duration); - _timer?.cancel(); + // In this case we need to stop _timer, set isPlaying=false, and + // position=value.duration. Instead of setting the values directly, + // we use pause() and seekTo() to ensure the platform stops playing + // and seeks to the last frame of the video. + pause().then((void pauseResult) => seekTo(value.duration)); break; case VideoEventType.bufferingUpdate: value = value.copyWith(buffered: event.buffered); @@ -385,10 +388,15 @@ class VideoPlayerController extends ValueNotifier { /// Starts playing the video. /// + /// If the video is at the end, this method starts playing from the beginning. + /// /// This method returns a future that completes as soon as the "play" command /// has been sent to the platform, not when playback itself is totally /// finished. Future play() async { + if (value.position == value.duration) { + await seekTo(const Duration()); + } value = value.copyWith(isPlaying: true); await _applyPlayPause(); } diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index c24377fc1b8b..b8684075b718 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.9 +version: 2.1.10 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index e17dac7897a6..63498c4e18cb 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -318,6 +318,23 @@ void main() { expect(fakeVideoPlayerPlatform.calls.last, 'setPlaybackSpeed'); }); + test('play restarts from beginning if video is at end', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + await controller.initialize(); + const Duration nonzeroDuration = Duration(milliseconds: 100); + controller.value = controller.value.copyWith(duration: nonzeroDuration); + await controller.seekTo(nonzeroDuration); + expect(controller.value.isPlaying, isFalse); + expect(controller.value.position, nonzeroDuration); + + await controller.play(); + + expect(controller.value.isPlaying, isTrue); + expect(controller.value.position, Duration.zero); + }); + test('setLooping', () async { final VideoPlayerController controller = VideoPlayerController.network( 'https://127.0.0.1', @@ -459,6 +476,8 @@ void main() { 'https://127.0.0.1', ); await controller.initialize(); + const Duration nonzeroDuration = Duration(milliseconds: 100); + controller.value = controller.value.copyWith(duration: nonzeroDuration); expect(controller.value.isPlaying, isFalse); await controller.play(); expect(controller.value.isPlaying, isTrue); @@ -470,7 +489,7 @@ void main() { await tester.pumpAndSettle(); expect(controller.value.isPlaying, isFalse); - expect(controller.value.position, controller.value.duration); + expect(controller.value.position, nonzeroDuration); }); testWidgets('buffering status', (WidgetTester tester) async { From 200a6ec1dbb3e50921cffe4f93970ed97ae7b012 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 8 Jul 2021 19:31:06 +0200 Subject: [PATCH 118/364] [in_app_purchase] Fix crash when retrieveReceiptWithError gives an error. (#4138) --- .../in_app_purchase/in_app_purchase_ios/CHANGELOG.md | 4 ++++ .../example/ios/RunnerTests/InAppPurchasePluginTests.m | 4 ++++ .../example/ios/RunnerTests/Stubs.m | 10 +++++++++- .../ios/Classes/FIAPReceiptManager.m | 9 +++++---- .../in_app_purchase/in_app_purchase_ios/pubspec.yaml | 2 +- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index d10edc97a82e..89d648af5d49 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.2+2 + +* Fix crash when retrieveReceiptWithError gives an error. + ## 0.1.2+1 * Fix wrong data type when cancelling user credentials dialog. diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m index e259e69d962c..79812f609980 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m @@ -251,6 +251,10 @@ - (void)testRetrieveReceiptDataError { [self waitForExpectations:@[ expectation ] timeout:5]; XCTAssertNotNil(result); XCTAssert([result isKindOfClass:[FlutterError class]]); + NSDictionary* details = ((FlutterError*)result).details; + XCTAssertNotNil(details[@"error"]); + NSNumber* errorCode = (NSNumber*)details[@"error"][@"code"]; + XCTAssertEqual(errorCode, [NSNumber numberWithInteger:99]); } - (void)testRefreshReceiptRequest { diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m index f247a7e4b78a..364505d6754a 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/Stubs.m @@ -261,7 +261,15 @@ @implementation FIAPReceiptManagerStub : FIAPReceiptManager - (NSData *)getReceiptData:(NSURL *)url error:(NSError **)error { if (self.returnError) { - *error = [[NSError alloc] init]; + *error = [NSError errorWithDomain:@"test" + code:1 + userInfo:@{ + @"name" : @"test", + @"houseNr" : @5, + @"error" : [[NSError alloc] initWithDomain:@"internalTestDomain" + code:99 + userInfo:nil] + }]; return nil; } NSString *originalString = [NSString stringWithFormat:@"test"]; diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m index 8038304d178f..b359b415d873 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPReceiptManager.m @@ -4,6 +4,7 @@ #import "FIAPReceiptManager.h" #import +#import "FIAObjectTranslator.h" @interface FIAPReceiptManager () // Gets the receipt file data from the location of the url. Can be nil if @@ -20,10 +21,10 @@ - (NSString *)retrieveReceiptWithError:(FlutterError **)flutterError { NSData *receipt = [self getReceiptData:receiptURL error:&receiptError]; if (!receipt || receiptError) { if (flutterError) { - *flutterError = [FlutterError - errorWithCode:[[NSString alloc] initWithFormat:@"%li", (long)receiptError.code] - message:receiptError.domain - details:receiptError.userInfo]; + NSDictionary *errorMap = [FIAObjectTranslator getMapFromNSError:receiptError]; + *flutterError = [FlutterError errorWithCode:errorMap[@"code"] + message:errorMap[@"domain"] + details:errorMap[@"userInfo"]]; } return nil; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 69afe52b6ac3..c277686fb7c7 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.2+1 +version: 0.1.2+2 environment: sdk: ">=2.12.0 <3.0.0" From 70017e57a277916223ffea7fd705054463f31633 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 8 Jul 2021 12:39:30 -0700 Subject: [PATCH 119/364] [flutter_plugin_tools] Only check target packages in analyze (#4146) Makes validating that there are no unexpected analysis_options.yaml files part of the per-package loop, rather than a pre-check, so that it only runs against the target packages. This makes it easier to run locally on specific packages, since it's not necessary to pass the allow list for every package when targetting just one package. --- script/tool/lib/src/analyze_command.dart | 17 +++++++++-------- script/tool/test/analyze_command_test.dart | 4 ++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index b8458da5228c..2c4fc1b8376e 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -11,7 +11,6 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; -const int _exitBadCustomAnalysisFile = 2; const int _exitPackagesGetFailed = 3; /// A command to run Dart analysis on packages. @@ -48,8 +47,8 @@ class AnalyzeCommand extends PackageLoopingCommand { final bool hasLongOutput = false; /// Checks that there are no unexpected analysis_options.yaml files. - void _validateAnalysisOptions() { - final List files = packagesDir.listSync(recursive: true); + bool _hasUnexpecetdAnalysisOptions(Directory package) { + final List files = package.listSync(recursive: true); for (final FileSystemEntity file in files) { if (file.basename != 'analysis_options.yaml' && file.basename != '.analysis_options') { @@ -60,7 +59,8 @@ class AnalyzeCommand extends PackageLoopingCommand { (String directory) => directory != null && directory.isNotEmpty && - p.isWithin(p.join(packagesDir.path, directory), file.path)); + p.isWithin( + packagesDir.childDirectory(directory).path, file.path)); if (allowed) { continue; } @@ -70,8 +70,9 @@ class AnalyzeCommand extends PackageLoopingCommand { printError( 'If this was deliberate, pass the package to the analyze command ' 'with the --$_customAnalysisFlag flag and try again.'); - throw ToolExit(_exitBadCustomAnalysisFile); + return true; } + return false; } /// Ensures that the dependent packages have been fetched for all packages @@ -100,9 +101,6 @@ class AnalyzeCommand extends PackageLoopingCommand { @override Future initializeRun() async { - print('Verifying analysis settings...'); - _validateAnalysisOptions(); - print('Fetching dependencies...'); if (!await _runPackagesGetOnTargetPackages()) { printError('Unable to get dependencies.'); @@ -116,6 +114,9 @@ class AnalyzeCommand extends PackageLoopingCommand { @override Future runForPackage(Directory package) async { + if (_hasUnexpecetdAnalysisOptions(package)) { + return PackageResult.fail(['Unexpected local analysis options']); + } final int exitCode = await processRunner.runAndStream( _dartBinaryPath, ['analyze', '--fatal-infos'], workingDir: package); diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 768463f0a5a2..adeaabaaca52 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -126,6 +126,8 @@ void main() { containsAllInOrder([ contains( 'Found an extra analysis_options.yaml at /packages/foo/analysis_options.yaml'), + contains(' foo:\n' + ' Unexpected local analysis options'), ]), ); }); @@ -146,6 +148,8 @@ void main() { containsAllInOrder([ contains( 'Found an extra analysis_options.yaml at /packages/foo/.analysis_options'), + contains(' foo:\n' + ' Unexpected local analysis options'), ]), ); }); From 7daf18988b63fe243aaa2cfe06408c8157bd1dac Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 8 Jul 2021 12:44:31 -0700 Subject: [PATCH 120/364] [flutter_plugin_tools] Improve and test 'format' (#4145) - Adds unit tests, as there are currently none. - Adds more graceful failure handling. - Adds an internal ignore list to skip files that don't need to be formatted that showed up during local testing. - Adds a note explaining that it's intentially not using the new base command due to performance issues. --- script/tool/lib/src/format_command.dart | 141 ++++++--- script/tool/test/format_command_test.dart | 344 ++++++++++++++++++++++ 2 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 script/tool/test/format_command_test.dart diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index 5f060d715bfd..9d39d93b9118 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -14,6 +14,11 @@ import 'common/core.dart'; import 'common/plugin_command.dart'; import 'common/process_runner.dart'; +const int _exitClangFormatFailed = 3; +const int _exitFlutterFormatFailed = 4; +const int _exitJavaFormatFailed = 5; +const int _exitGitFailed = 6; + final Uri _googleFormatterUrl = Uri.https('github.com', '/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar'); @@ -43,14 +48,18 @@ class FormatCommand extends PluginCommand { Future run() async { final String googleFormatterPath = await _getGoogleFormatterPath(); - await _formatDart(); - await _formatJava(googleFormatterPath); - await _formatCppAndObjectiveC(); + // This class is not based on PackageLoopingCommand because running the + // formatters separately for each package is an order of magnitude slower, + // due to the startup overhead of the formatters. + final Iterable files = await _getFilteredFilePaths(getFiles()); + await _formatDart(files); + await _formatJava(files, googleFormatterPath); + await _formatCppAndObjectiveC(files); if (getBoolArg('fail-on-change')) { final bool modified = await _didModifyAnything(); if (modified) { - throw ToolExit(1); + throw ToolExit(exitCommandFoundErrors); } } } @@ -60,9 +69,12 @@ class FormatCommand extends PluginCommand { 'git', ['ls-files', '--modified'], workingDir: packagesDir, - exitOnError: true, logOnError: true, ); + if (modifiedFiles.exitCode != 0) { + printError('Unable to determine changed files.'); + throw ToolExit(_exitGitFailed); + } print('\n\n'); @@ -79,66 +91,105 @@ class FormatCommand extends PluginCommand { 'pub global run flutter_plugin_tools format" or copy-paste ' 'this command into your terminal:'); - print('patch -p1 <['diff'], workingDir: packagesDir, - exitOnError: true, logOnError: true, ); + if (diff.exitCode != 0) { + printError('Unable to determine diff.'); + throw ToolExit(_exitGitFailed); + } + print('patch -p1 < _formatCppAndObjectiveC() async { - print('Formatting all .cc, .cpp, .mm, .m, and .h files...'); - final Iterable allFiles = [ - ...await _getFilesWithExtension('.h'), - ...await _getFilesWithExtension('.m'), - ...await _getFilesWithExtension('.mm'), - ...await _getFilesWithExtension('.cc'), - ...await _getFilesWithExtension('.cpp'), - ]; - // Split this into multiple invocations to avoid a - // 'ProcessException: Argument list too long'. - final Iterable> batches = partition(allFiles, 100); - for (final List batch in batches) { - await processRunner.runAndStream(getStringArg('clang-format'), - ['-i', '--style=Google', ...batch], - workingDir: packagesDir, exitOnError: true); + Future _formatCppAndObjectiveC(Iterable files) async { + final Iterable clangFiles = _getPathsWithExtensions( + files, {'.h', '.m', '.mm', '.cc', '.cpp'}); + if (clangFiles.isNotEmpty) { + print('Formatting .cc, .cpp, .h, .m, and .mm files...'); + final Iterable> batches = partition(clangFiles, 100); + int exitCode = 0; + for (final List batch in batches) { + batch.sort(); // For ease of testing; partition changes the order. + exitCode = await processRunner.runAndStream( + getStringArg('clang-format'), + ['-i', '--style=Google', ...batch], + workingDir: packagesDir); + if (exitCode != 0) { + break; + } + } + if (exitCode != 0) { + printError( + 'Failed to format C, C++, and Objective-C files: exit code $exitCode.'); + throw ToolExit(_exitClangFormatFailed); + } } } - Future _formatJava(String googleFormatterPath) async { - print('Formatting all .java files...'); - final Iterable javaFiles = await _getFilesWithExtension('.java'); - await processRunner.runAndStream('java', - ['-jar', googleFormatterPath, '--replace', ...javaFiles], - workingDir: packagesDir, exitOnError: true); + Future _formatJava( + Iterable files, String googleFormatterPath) async { + final Iterable javaFiles = + _getPathsWithExtensions(files, {'.java'}); + if (javaFiles.isNotEmpty) { + print('Formatting .java files...'); + final int exitCode = await processRunner.runAndStream('java', + ['-jar', googleFormatterPath, '--replace', ...javaFiles], + workingDir: packagesDir); + if (exitCode != 0) { + printError('Failed to format Java files: exit code $exitCode.'); + throw ToolExit(_exitJavaFormatFailed); + } + } } - Future _formatDart() async { - // This actually should be fine for non-Flutter Dart projects, no need to - // specifically shell out to dartfmt -w in that case. - print('Formatting all .dart files...'); - final Iterable dartFiles = await _getFilesWithExtension('.dart'); - if (dartFiles.isEmpty) { - print( - 'No .dart files to format. If you set the `--exclude` flag, most likey they were skipped'); - } else { - await processRunner.runAndStream( + Future _formatDart(Iterable files) async { + final Iterable dartFiles = + _getPathsWithExtensions(files, {'.dart'}); + if (dartFiles.isNotEmpty) { + print('Formatting .dart files...'); + // `flutter format` doesn't require the project to actually be a Flutter + // project. + final int exitCode = await processRunner.runAndStream( 'flutter', ['format', ...dartFiles], - workingDir: packagesDir, exitOnError: true); + workingDir: packagesDir); + if (exitCode != 0) { + printError('Failed to format Dart files: exit code $exitCode.'); + throw ToolExit(_exitFlutterFormatFailed); + } } } - Future> _getFilesWithExtension(String extension) async => - getFiles() - .where((File file) => p.extension(file.path) == extension) - .map((File file) => file.path) - .toList(); + Future> _getFilteredFilePaths(Stream files) async { + // Returns a pattern to check for [directories] as a subset of a file path. + RegExp pathFragmentForDirectories(List directories) { + final String s = p.separator; + return RegExp('(?:^|$s)${p.joinAll(directories)}$s'); + } + + return files + .map((File file) => file.path) + .where((String path) => + // Ignore files in build/ directories (e.g., headers of frameworks) + // to avoid useless extra work in local repositories. + !path.contains( + pathFragmentForDirectories(['example', 'build'])) && + // Ignore files in Pods, which are not part of the repository. + !path.contains(pathFragmentForDirectories(['Pods'])) && + // Ignore .dart_tool/, which can have various intermediate files. + !path.contains(pathFragmentForDirectories(['.dart_tool']))) + .toList(); + } + + Iterable _getPathsWithExtensions( + Iterable files, Set extensions) { + return files.where((String path) => extensions.contains(p.extension(path))); + } Future _getGoogleFormatterPath() async { final String javaFormatterPath = p.join( diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart new file mode 100644 index 000000000000..e7f4d795eb93 --- /dev/null +++ b/script/tool/test/format_command_test.dart @@ -0,0 +1,344 @@ +// Copyright 2013 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:io' as io; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/format_command.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +void main() { + late FileSystem fileSystem; + late Directory packagesDir; + late RecordingProcessRunner processRunner; + late CommandRunner runner; + late String javaFormatPath; + + setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); + final FormatCommand analyzeCommand = + FormatCommand(packagesDir, processRunner: processRunner); + + // Create the java formatter file that the command checks for, to avoid + // a download. + javaFormatPath = p.join(p.dirname(p.fromUri(io.Platform.script)), + 'google-java-format-1.3-all-deps.jar'); + fileSystem.file(javaFormatPath).createSync(recursive: true); + + runner = CommandRunner('format_command', 'Test for format_command'); + runner.addCommand(analyzeCommand); + }); + + List _getAbsolutePaths( + Directory package, List relativePaths) { + return relativePaths + .map((String path) => p.join(package.path, path)) + .toList(); + } + + test('formats .dart files', () async { + const List files = [ + 'lib/a.dart', + 'lib/src/b.dart', + 'lib/src/c.dart', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'flutter', + ['format', ..._getAbsolutePaths(pluginDir, files)], + packagesDir.path), + ])); + }); + + test('fails if flutter format fails', () async { + const List files = [ + 'lib/a.dart', + 'lib/src/b.dart', + 'lib/src/c.dart', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess.failing() + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Failed to format Dart files: exit code 1.'), + ])); + }); + + test('formats .java files', () async { + const List files = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', + 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'java', + [ + '-jar', + javaFormatPath, + '--replace', + ..._getAbsolutePaths(pluginDir, files) + ], + packagesDir.path), + ])); + }); + + test('fails if Java formatter fails', () async { + const List files = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', + 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['java'] = [ + MockProcess.failing() + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Failed to format Java files: exit code 1.'), + ])); + }); + + test('formats c-ish files', () async { + const List files = [ + 'ios/Classes/Foo.h', + 'ios/Classes/Foo.m', + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + 'macos/Classes/Foo.mm', + 'windows/foo_plugin.cpp', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'clang-format', + [ + '-i', + '--style=Google', + ..._getAbsolutePaths(pluginDir, files) + ], + packagesDir.path), + ])); + }); + + test('fails if clang-format fails', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['clang-format'] = [ + MockProcess.failing() + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Failed to format C, C++, and Objective-C files: exit code 1.'), + ])); + }); + + test('skips known non-repo files', () async { + const List skipFiles = [ + '/example/build/SomeFramework.framework/Headers/SomeFramework.h', + '/example/Pods/APod.framework/Headers/APod.h', + '.dart_tool/internals/foo.cc', + '.dart_tool/internals/Bar.java', + '.dart_tool/internals/baz.dart', + ]; + const List clangFiles = ['ios/Classes/Foo.h']; + const List dartFiles = ['lib/a.dart']; + const List javaFiles = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java' + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: [ + ...skipFiles, + // Include some files that should be formatted to validate that it's + // correctly filtering even when running the commands. + ...clangFiles, + ...dartFiles, + ...javaFiles, + ], + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + containsAll([ + ProcessCall( + 'clang-format', + [ + '-i', + '--style=Google', + ..._getAbsolutePaths(pluginDir, clangFiles) + ], + packagesDir.path), + ProcessCall( + 'flutter', + ['format', ..._getAbsolutePaths(pluginDir, dartFiles)], + packagesDir.path), + ProcessCall( + 'java', + [ + '-jar', + javaFormatPath, + '--replace', + ..._getAbsolutePaths(pluginDir, javaFiles) + ], + packagesDir.path), + ])); + }); + + test('fails if files are changed with --file-on-change', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['git'] = [ + MockProcess.succeeding(), + ]; + const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; + processRunner.resultStdout = changedFilePath; + Error? commandError; + final List output = + await runCapturingPrint(runner, ['format', '--fail-on-change'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('These files are not formatted correctly'), + contains(changedFilePath), + contains('patch -p1 < files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['git'] = [ + MockProcess.failing() + ]; + Error? commandError; + final List output = + await runCapturingPrint(runner, ['format', '--fail-on-change'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to determine changed files.'), + ])); + }); + + test('reports git diff failures', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['git'] = [ + MockProcess.succeeding(), // ls-files + MockProcess.failing(), // diff + ]; + const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; + processRunner.resultStdout = changedFilePath; + Error? commandError; + final List output = + await runCapturingPrint(runner, ['format', '--fail-on-change'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('These files are not formatted correctly'), + contains(changedFilePath), + contains('Unable to determine diff.'), + ])); + }); +} From 06fc7d105446592b31c5e4c679c1dfcb6c241633 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Thu, 8 Jul 2021 23:08:24 +0200 Subject: [PATCH 121/364] [image_picker_platform_interface] Add methods that return package:cross_file (#4072) --- .../CHANGELOG.md | 9 + .../lib/image_picker_platform_interface.dart | 1 + .../method_channel_image_picker.dart | 104 +++- .../image_picker_platform.dart | 109 +++- .../lib/src/types/lost_data_response.dart | 53 ++ .../lib/src/types/types.dart | 1 + .../pubspec.yaml | 3 +- .../new_method_channel_image_picker_test.dart | 472 +++++++++++++++++- 8 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart diff --git a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md index e2def7243592..bd56f0ca77a6 100644 --- a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md +++ b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md @@ -1,3 +1,12 @@ +## 2.2.0 + +* Added new methods that return `XFile` (from `package:cross_file`) + * `getImage` (will deprecate `pickImage`) + * `getVideo` (will deprecate `pickVideo`) + * `getMultiImage` (will deprecate `pickMultiImage`) + +_`PickedFile` will also be marked as deprecated in an upcoming release._ + ## 2.1.0 * Add `pickMultiImage` method. diff --git a/packages/image_picker/image_picker_platform_interface/lib/image_picker_platform_interface.dart b/packages/image_picker/image_picker_platform_interface/lib/image_picker_platform_interface.dart index b384e3845c4b..133c05ecfebf 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/image_picker_platform_interface.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/image_picker_platform_interface.dart @@ -4,3 +4,4 @@ export 'package:image_picker_platform_interface/src/platform_interface/image_picker_platform.dart'; export 'package:image_picker_platform_interface/src/types/types.dart'; +export 'package:cross_file/cross_file.dart'; diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart index e0f46457a8b8..bb9e18e78d83 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart @@ -26,7 +26,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { int? imageQuality, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { - String? path = await _pickImagePath( + String? path = await _getImagePath( source: source, maxWidth: maxWidth, maxHeight: maxHeight, @@ -42,21 +42,17 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxHeight, int? imageQuality, }) async { - final List? paths = await _pickMultiImagePath( + final List? paths = await _getMultiImagePath( maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: imageQuality, ); if (paths == null) return null; - final List files = []; - for (final path in paths) { - files.add(PickedFile(path)); - } - return files; + return paths.map((path) => PickedFile(path)).toList(); } - Future?> _pickMultiImagePath({ + Future?> _getMultiImagePath({ double? maxWidth, double? maxHeight, int? imageQuality, @@ -84,7 +80,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { ); } - Future _pickImagePath({ + Future _getImagePath({ required ImageSource source, double? maxWidth, double? maxHeight, @@ -122,7 +118,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { CameraDevice preferredCameraDevice = CameraDevice.rear, Duration? maxDuration, }) async { - final String? path = await _pickVideoPath( + final String? path = await _getVideoPath( source: source, maxDuration: maxDuration, preferredCameraDevice: preferredCameraDevice, @@ -130,7 +126,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { return path != null ? PickedFile(path) : null; } - Future _pickVideoPath({ + Future _getVideoPath({ required ImageSource source, CameraDevice preferredCameraDevice = CameraDevice.rear, Duration? maxDuration, @@ -154,7 +150,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { return LostData.empty(); } - assert(result.containsKey('path') ^ result.containsKey('errorCode')); + assert(result.containsKey('path') != result.containsKey('errorCode')); final String? type = result['type']; assert(type == kTypeImage || type == kTypeVideo); @@ -180,4 +176,88 @@ class MethodChannelImagePicker extends ImagePickerPlatform { type: retrieveType, ); } + + @override + Future getImage({ + required ImageSource source, + double? maxWidth, + double? maxHeight, + int? imageQuality, + CameraDevice preferredCameraDevice = CameraDevice.rear, + }) async { + String? path = await _getImagePath( + source: source, + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + preferredCameraDevice: preferredCameraDevice, + ); + return path != null ? XFile(path) : null; + } + + @override + Future?> getMultiImage({ + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) async { + final List? paths = await _getMultiImagePath( + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + ); + if (paths == null) return null; + + return paths.map((path) => XFile(path)).toList(); + } + + @override + Future getVideo({ + required ImageSource source, + CameraDevice preferredCameraDevice = CameraDevice.rear, + Duration? maxDuration, + }) async { + final String? path = await _getVideoPath( + source: source, + maxDuration: maxDuration, + preferredCameraDevice: preferredCameraDevice, + ); + return path != null ? XFile(path) : null; + } + + @override + Future getLostData() async { + final Map? result = + await _channel.invokeMapMethod('retrieve'); + + if (result == null) { + return LostDataResponse.empty(); + } + + assert(result.containsKey('path') != result.containsKey('errorCode')); + + final String? type = result['type']; + assert(type == kTypeImage || type == kTypeVideo); + + RetrieveType? retrieveType; + if (type == kTypeImage) { + retrieveType = RetrieveType.image; + } else if (type == kTypeVideo) { + retrieveType = RetrieveType.video; + } + + PlatformException? exception; + if (result.containsKey('errorCode')) { + exception = PlatformException( + code: result['errorCode'], message: result['errorMessage']); + } + + final String? path = result['path']; + + return LostDataResponse( + file: path != null ? XFile(path) : null, + exception: exception, + type: retrieveType, + ); + } } diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart index 32af7747185a..8f9ab99eae06 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart @@ -4,8 +4,8 @@ import 'dart:async'; +import 'package:cross_file/cross_file.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - import 'package:image_picker_platform_interface/src/method_channel/method_channel_image_picker.dart'; import 'package:image_picker_platform_interface/src/types/types.dart'; @@ -144,4 +144,111 @@ abstract class ImagePickerPlatform extends PlatformInterface { Future retrieveLostData() { throw UnimplementedError('retrieveLostData() has not been implemented.'); } + + /// Returns an [XFile] with the image that was picked. + /// + /// The `source` argument controls where the image comes from. This can + /// be either [ImageSource.camera] or [ImageSource.gallery]. + /// + /// Where iOS supports HEIC images, Android 8 and below doesn't. Android 9 and above only support HEIC images if used + /// in addition to a size modification, of which the usage is explained below. + /// + /// If specified, the image will be at most `maxWidth` wide and + /// `maxHeight` tall. Otherwise the image will be returned at it's + /// original width and height. + /// + /// The `imageQuality` argument modifies the quality of the image, ranging from 0-100 + /// where 100 is the original/max quality. If `imageQuality` is null, the image with + /// the original quality will be returned. Compression is only supported for certain + /// image types such as JPEG. If compression is not supported for the image that is picked, + /// a warning message will be logged. + /// + /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. + /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. + /// Defaults to [CameraDevice.rear]. Note that Android has no documented parameter for an intent to specify if + /// the front or rear camera should be opened, this function is not guaranteed + /// to work on an Android device. + /// + /// In Android, the MainActivity can be destroyed for various reasons. If that happens, the result will be lost + /// in this call. You can then call [getLostData] when your app relaunches to retrieve the lost data. + /// + /// If no images were picked, the return value is null. + Future getImage({ + required ImageSource source, + double? maxWidth, + double? maxHeight, + int? imageQuality, + CameraDevice preferredCameraDevice = CameraDevice.rear, + }) { + throw UnimplementedError('getImage() has not been implemented.'); + } + + /// Returns a [List] with the images that were picked. + /// + /// The images come from the [ImageSource.gallery]. + /// + /// Where iOS supports HEIC images, Android 8 and below doesn't. Android 9 and above only support HEIC images if used + /// in addition to a size modification, of which the usage is explained below. + /// + /// If specified, the image will be at most `maxWidth` wide and + /// `maxHeight` tall. Otherwise the image will be returned at it's + /// original width and height. + /// + /// The `imageQuality` argument modifies the quality of the images, ranging from 0-100 + /// where 100 is the original/max quality. If `imageQuality` is null, the images with + /// the original quality will be returned. Compression is only supported for certain + /// image types such as JPEG. If compression is not supported for the image that is picked, + /// a warning message will be logged. + /// + /// If no images were picked, the return value is null. + Future?> getMultiImage({ + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) { + throw UnimplementedError('getMultiImage() has not been implemented.'); + } + + /// Returns a [XFile] containing the video that was picked. + /// + /// The [source] argument controls where the video comes from. This can + /// be either [ImageSource.camera] or [ImageSource.gallery]. + /// + /// The [maxDuration] argument specifies the maximum duration of the captured video. If no [maxDuration] is specified, + /// the maximum duration will be infinite. + /// + /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. + /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. + /// Defaults to [CameraDevice.rear]. + /// + /// In Android, the MainActivity can be destroyed for various fo reasons. If that happens, the result will be lost + /// in this call. You can then call [getLostData] when your app relaunches to retrieve the lost data. + /// + /// If no images were picked, the return value is null. + Future getVideo({ + required ImageSource source, + CameraDevice preferredCameraDevice = CameraDevice.rear, + Duration? maxDuration, + }) { + throw UnimplementedError('getVideo() has not been implemented.'); + } + + /// Retrieve the lost [XFile] file when [getImage], [getMultiImage] or [getVideo] failed because the MainActivity is + /// destroyed. (Android only) + /// + /// Image or video can be lost if the MainActivity is destroyed. And there is no guarantee that the MainActivity is + /// always alive. Call this method to retrieve the lost data and process the data according to your APP's business logic. + /// + /// Returns a [LostDataResponse] object if successfully retrieved the lost data. The [LostDataResponse] object can + /// represent either a successful image/video selection, or a failure. + /// + /// Calling this on a non-Android platform will throw [UnimplementedError] exception. + /// + /// See also: + /// * [LostDataResponse], for what's included in the response. + /// * [Android Activity Lifecycle](https://developer.android.com/reference/android/app/Activity.html), for more + /// information on MainActivity destruction. + Future getLostData() { + throw UnimplementedError('getLostData() has not been implemented.'); + } } diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart new file mode 100644 index 000000000000..576ad334bd35 --- /dev/null +++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart @@ -0,0 +1,53 @@ +// Copyright 2013 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 'package:cross_file/cross_file.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker_platform_interface/src/types/types.dart'; + +/// The response object of [ImagePicker.getLostData]. +/// +/// Only applies to Android. +/// See also: +/// * [ImagePicker.getLostData] for more details on retrieving lost data. +class LostDataResponse { + /// Creates an instance with the given [file], [exception], and [type]. Any of + /// the params may be null, but this is never considered to be empty. + LostDataResponse({this.file, this.exception, this.type}); + + /// Initializes an instance with all member params set to null and considered + /// to be empty. + LostDataResponse.empty() + : file = null, + exception = null, + type = null, + _empty = true; + + /// Whether it is an empty response. + /// + /// An empty response should have [file], [exception] and [type] to be null. + bool get isEmpty => _empty; + + /// The file that was lost in a previous [getImage], [getMultiImage] or [getVideo] call due to MainActivity being destroyed. + /// + /// Can be null if [exception] exists. + final XFile? file; + + /// The exception of the last [getImage], [getMultiImage] or [getVideo]. + /// + /// If the last [getImage], [getMultiImage] or [getVideo] threw some exception before the MainActivity destruction, + /// this variable keeps that exception. + /// You should handle this exception as if the [getImage], [getMultiImage] or [getVideo] got an exception when + /// the MainActivity was not destroyed. + /// + /// Note that it is not the exception that caused the destruction of the MainActivity. + final PlatformException? exception; + + /// Can either be [RetrieveType.image] or [RetrieveType.video]; + /// + /// If the lost data is empty, this will be null. + final RetrieveType? type; + + bool _empty = false; +} diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart index 10e7745f2741..ad7cd3fbcaab 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart @@ -6,6 +6,7 @@ export 'camera_device.dart'; export 'image_source.dart'; export 'retrieve_type.dart'; export 'picked_file/picked_file.dart'; +export 'lost_data_response.dart'; /// Denotes that an image is being picked. const String kTypeImage = 'image'; diff --git a/packages/image_picker/image_picker_platform_interface/pubspec.yaml b/packages/image_picker/image_picker_platform_interface/pubspec.yaml index 8e176a09a626..0953e76f03ee 100644 --- a/packages/image_picker/image_picker_platform_interface/pubspec.yaml +++ b/packages/image_picker/image_picker_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.1.0 +version: 2.2.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -16,6 +16,7 @@ dependencies: http: ^0.13.0 meta: ^1.3.0 plugin_platform_interface: ^2.0.0 + cross_file: ^0.3.1+1 dev_dependencies: flutter_test: diff --git a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart index 83ae6fac9071..e5321abc0121 100644 --- a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart +++ b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart @@ -342,7 +342,7 @@ void main() { }); }); - group('#pickVideoPath', () { + group('#pickVideo', () { test('passes the image source argument correctly', () async { await picker.pickVideo(source: ImageSource.camera); await picker.pickVideo(source: ImageSource.gallery); @@ -455,7 +455,6 @@ void main() { 'path': '/example/path', }; }); - // ignore: deprecated_member_use_from_same_package final LostData response = await picker.retrieveLostData(); expect(response.type, RetrieveType.image); expect(response.file, isNotNull); @@ -470,7 +469,6 @@ void main() { 'errorMessage': 'test_error_message', }; }); - // ignore: deprecated_member_use_from_same_package final LostData response = await picker.retrieveLostData(); expect(response.type, RetrieveType.video); expect(response.exception, isNotNull); @@ -497,5 +495,473 @@ void main() { expect(picker.retrieveLostData(), throwsAssertionError); }); }); + + group('#getImage', () { + test('passes the image source argument correctly', () async { + await picker.getImage(source: ImageSource.camera); + await picker.getImage(source: ImageSource.gallery); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 1, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + ], + ); + }); + + test('passes the width and height arguments correctly', () async { + await picker.getImage(source: ImageSource.camera); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + ); + await picker.getImage( + source: ImageSource.camera, + maxHeight: 10.0, + ); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + maxHeight: 20.0, + ); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + imageQuality: 70, + ); + await picker.getImage( + source: ImageSource.camera, + maxHeight: 10.0, + imageQuality: 70, + ); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + maxHeight: 20.0, + imageQuality: 70, + ); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + ], + ); + }); + + test('does not accept a invalid imageQuality argument', () { + expect( + () => picker.getImage(imageQuality: -1, source: ImageSource.gallery), + throwsArgumentError, + ); + + expect( + () => picker.getImage(imageQuality: 101, source: ImageSource.gallery), + throwsArgumentError, + ); + + expect( + () => picker.getImage(imageQuality: -1, source: ImageSource.camera), + throwsArgumentError, + ); + + expect( + () => picker.getImage(imageQuality: 101, source: ImageSource.camera), + throwsArgumentError, + ); + }); + + test('does not accept a negative width or height argument', () { + expect( + () => picker.getImage(source: ImageSource.camera, maxWidth: -1.0), + throwsArgumentError, + ); + + expect( + () => picker.getImage(source: ImageSource.camera, maxHeight: -1.0), + throwsArgumentError, + ); + }); + + test('handles a null image path response gracefully', () async { + picker.channel + .setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getImage(source: ImageSource.gallery), isNull); + expect(await picker.getImage(source: ImageSource.camera), isNull); + }); + + test('camera position defaults to back', () async { + await picker.getImage(source: ImageSource.camera); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0, + }), + ], + ); + }); + + test('camera position can set to front', () async { + await picker.getImage( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.front); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 1, + }), + ], + ); + }); + }); + + group('#getMultiImage', () { + test('calls the method correctly', () async { + returnValue = ['0', '1']; + await picker.getMultiImage(); + + expect( + log, + [ + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + }), + ], + ); + }); + + test('passes the width and height arguments correctly', () async { + returnValue = ['0', '1']; + await picker.getMultiImage(); + await picker.getMultiImage( + maxWidth: 10.0, + ); + await picker.getMultiImage( + maxHeight: 10.0, + ); + await picker.getMultiImage( + maxWidth: 10.0, + maxHeight: 20.0, + ); + await picker.getMultiImage( + maxWidth: 10.0, + imageQuality: 70, + ); + await picker.getMultiImage( + maxHeight: 10.0, + imageQuality: 70, + ); + await picker.getMultiImage( + maxWidth: 10.0, + maxHeight: 20.0, + imageQuality: 70, + ); + + expect( + log, + [ + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': 70, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': 70, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': 70, + }), + ], + ); + }); + + test('does not accept a negative width or height argument', () { + returnValue = ['0', '1']; + expect( + () => picker.getMultiImage(maxWidth: -1.0), + throwsArgumentError, + ); + + expect( + () => picker.getMultiImage(maxHeight: -1.0), + throwsArgumentError, + ); + }); + + test('does not accept a invalid imageQuality argument', () { + returnValue = ['0', '1']; + expect( + () => picker.getMultiImage(imageQuality: -1), + throwsArgumentError, + ); + + expect( + () => picker.getMultiImage(imageQuality: 101), + throwsArgumentError, + ); + }); + + test('handles a null image path response gracefully', () async { + picker.channel + .setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getMultiImage(), isNull); + expect(await picker.getMultiImage(), isNull); + }); + }); + + group('#getVideo', () { + test('passes the image source argument correctly', () async { + await picker.getVideo(source: ImageSource.camera); + await picker.getVideo(source: ImageSource.gallery); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'cameraDevice': 0, + 'maxDuration': null, + }), + isMethodCall('pickVideo', arguments: { + 'source': 1, + 'cameraDevice': 0, + 'maxDuration': null, + }), + ], + ); + }); + + test('passes the duration argument correctly', () async { + await picker.getVideo(source: ImageSource.camera); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(seconds: 10), + ); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(minutes: 1), + ); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(hours: 1), + ); + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': null, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 10, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 60, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 3600, + 'cameraDevice': 0, + }), + ], + ); + }); + + test('handles a null video path response gracefully', () async { + picker.channel + .setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getVideo(source: ImageSource.gallery), isNull); + expect(await picker.getVideo(source: ImageSource.camera), isNull); + }); + + test('camera position defaults to back', () async { + await picker.getVideo(source: ImageSource.camera); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'cameraDevice': 0, + 'maxDuration': null, + }), + ], + ); + }); + + test('camera position can set to front', () async { + await picker.getVideo( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.front, + ); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': null, + 'cameraDevice': 1, + }), + ], + ); + }); + }); + + group('#getLostData', () { + test('getLostData get success response', () async { + picker.channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'image', + 'path': '/example/path', + }; + }); + final LostDataResponse response = await picker.getLostData(); + expect(response.type, RetrieveType.image); + expect(response.file, isNotNull); + expect(response.file!.path, '/example/path'); + }); + + test('getLostData get error response', () async { + picker.channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'video', + 'errorCode': 'test_error_code', + 'errorMessage': 'test_error_message', + }; + }); + final LostDataResponse response = await picker.getLostData(); + expect(response.type, RetrieveType.video); + expect(response.exception, isNotNull); + expect(response.exception!.code, 'test_error_code'); + expect(response.exception!.message, 'test_error_message'); + }); + + test('getLostData get null response', () async { + picker.channel.setMockMethodCallHandler((MethodCall methodCall) async { + return null; + }); + expect((await picker.getLostData()).isEmpty, true); + }); + + test('getLostData get both path and error should throw', () async { + picker.channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'video', + 'errorCode': 'test_error_code', + 'errorMessage': 'test_error_message', + 'path': '/example/path', + }; + }); + expect(picker.getLostData(), throwsAssertionError); + }); + }); }); } From e78b483f34aad1d608bae90f904d522e89810d41 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 9 Jul 2021 09:01:05 +0200 Subject: [PATCH 122/364] [in_app_purchase] Added priceCurrencySymbol to SkuDetailsWrapper (#4114) --- .../in_app_purchase_android/CHANGELOG.md | 4 ++++ .../plugins/inapppurchase/Translator.java | 20 +++++++++++++++++ .../plugins/inapppurchase/TranslatorTest.java | 22 +++++++++++++++++++ .../sku_details_wrapper.dart | 7 ++++++ .../sku_details_wrapper.g.dart | 2 ++ .../types/google_play_product_details.dart | 3 +++ .../in_app_purchase_android/pubspec.yaml | 4 ++-- .../sku_details_wrapper_test.dart | 2 ++ ...in_app_purchase_android_platform_test.dart | 1 + 9 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 316a67b9ce99..824b432d5021 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.4+2 + +* Added price currency symbol to SkuDetailsWrapper. + ## 0.1.4+1 * Fixed typos. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 079c18ab8b5c..7546fe7db58d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -13,8 +13,10 @@ import com.android.billingclient.api.SkuDetails; import java.util.ArrayList; import java.util.Collections; +import java.util.Currency; import java.util.HashMap; import java.util.List; +import java.util.Locale; /** Handles serialization of {@link com.android.billingclient.api.BillingClient} related objects. */ /*package*/ class Translator { @@ -30,6 +32,7 @@ static HashMap fromSkuDetail(SkuDetails detail) { info.put("price", detail.getPrice()); info.put("priceAmountMicros", detail.getPriceAmountMicros()); info.put("priceCurrencyCode", detail.getPriceCurrencyCode()); + info.put("priceCurrencySymbol", currencySymbolFromCode(detail.getPriceCurrencyCode())); info.put("sku", detail.getSku()); info.put("type", detail.getType()); info.put("subscriptionPeriod", detail.getSubscriptionPeriod()); @@ -123,4 +126,21 @@ static HashMap fromBillingResult(BillingResult billingResult) { info.put("debugMessage", billingResult.getDebugMessage()); return info; } + + /** + * Gets the symbol of for the given currency code for the default {@link Locale.Category#DISPLAY + * DISPLAY} locale. For example, for the US Dollar, the symbol is "$" if the default locale is the + * US, while for other locales it may be "US$". If no symbol can be determined, the ISO 4217 + * currency code is returned. + * + * @param currencyCode the ISO 4217 code of the currency + * @return the symbol of this currency code for the default {@link Locale.Category#DISPLAY + * DISPLAY} locale + * @exception NullPointerException if currencyCode is null + * @exception IllegalArgumentException if currencyCode is not a supported ISO 4217 + * code. + */ + static String currencySymbolFromCode(String currencyCode) { + return Currency.getInstance(currencyCode).getSymbol(); + } } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index e65afcf42467..2837dceea652 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -7,6 +7,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -22,8 +24,10 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.json.JSONException; +import org.junit.Before; import org.junit.Test; public class TranslatorTest { @@ -32,6 +36,12 @@ public class TranslatorTest { private static final String PURCHASE_EXAMPLE_JSON = "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\", \"obfuscatedAccountId\":\"Account101\", \"obfuscatedProfileId\": \"Profile105\"}"; + @Before + public void setup() { + Locale locale = new Locale("en", "us"); + Locale.setDefault(locale); + } + @Test public void fromSkuDetail() throws JSONException { final SkuDetails expected = new SkuDetails(SKU_DETAIL_EXAMPLE_JSON); @@ -182,6 +192,17 @@ public void fromBillingResult_debugMessageNull() throws JSONException { assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); } + @Test + public void currencyCodeFromSymbol() { + assertEquals("$", Translator.currencySymbolFromCode("USD")); + try { + Translator.currencySymbolFromCode("EUROPACOIN"); + fail("Translator should throw an exception"); + } catch (Exception e) { + assertTrue(e instanceof IllegalArgumentException); + } + } + private void assertSerialized(SkuDetails expected, Map serialized) { assertEquals(expected.getDescription(), serialized.get("description")); assertEquals(expected.getFreeTrialPeriod(), serialized.get("freeTrialPeriod")); @@ -194,6 +215,7 @@ private void assertSerialized(SkuDetails expected, Map serialize assertEquals(expected.getPrice(), serialized.get("price")); assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); + assertEquals("$", serialized.get("priceCurrencySymbol")); assertEquals(expected.getSku(), serialized.get("sku")); assertEquals(expected.getSubscriptionPeriod(), serialized.get("subscriptionPeriod")); assertEquals(expected.getTitle(), serialized.get("title")); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index e3d13df2262a..da4d5c73d851 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -38,6 +38,7 @@ class SkuDetailsWrapper { required this.price, required this.priceAmountMicros, required this.priceCurrencyCode, + required this.priceCurrencySymbol, required this.sku, required this.subscriptionPeriod, required this.title, @@ -91,6 +92,12 @@ class SkuDetailsWrapper { @JsonKey(defaultValue: '') final String priceCurrencyCode; + /// [price] localized currency symbol + /// For example, for the US Dollar, the symbol is "$" if the locale + /// is the US, while for other locales it may be "US$". + @JsonKey(defaultValue: '') + final String priceCurrencySymbol; + /// The product ID in Google Play Console. @JsonKey(defaultValue: '') final String sku; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart index a14affdf9ed3..49e86087bc13 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -17,6 +17,7 @@ SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { price: json['price'] as String? ?? '', priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', + priceCurrencySymbol: json['priceCurrencySymbol'] as String? ?? '', sku: json['sku'] as String? ?? '', subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', title: json['title'] as String? ?? '', @@ -37,6 +38,7 @@ Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => 'price': instance.price, 'priceAmountMicros': instance.priceAmountMicros, 'priceCurrencyCode': instance.priceCurrencyCode, + 'priceCurrencySymbol': instance.priceCurrencySymbol, 'sku': instance.sku, 'subscriptionPeriod': instance.subscriptionPeriod, 'title': instance.title, diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart index 62589038804e..59d33fe26223 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -18,6 +18,7 @@ class GooglePlayProductDetails extends ProductDetails { required double rawPrice, required String currencyCode, required this.skuDetails, + required String currencySymbol, }) : super( id: id, title: title, @@ -25,6 +26,7 @@ class GooglePlayProductDetails extends ProductDetails { price: price, rawPrice: rawPrice, currencyCode: currencyCode, + currencySymbol: currencySymbol, ); /// Points back to the [SkuDetailsWrapper] object that was used to generate @@ -43,6 +45,7 @@ class GooglePlayProductDetails extends ProductDetails { price: skuDetails.price, rawPrice: ((skuDetails.priceAmountMicros) / 1000000.0).toDouble(), currencyCode: skuDetails.priceCurrencyCode, + currencySymbol: skuDetails.priceCurrencySymbol, skuDetails: skuDetails, ); } diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index e0de3411e0ff..41136e7501f6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+1 +version: 0.1.4+2 environment: sdk: ">=2.12.0 <3.0.0" @@ -19,7 +19,7 @@ dependencies: collection: ^1.15.0 flutter: sdk: flutter - in_app_purchase_platform_interface: ^1.0.0 + in_app_purchase_platform_interface: ^1.1.0 json_annotation: ^4.0.1 meta: ^1.3.0 test: ^1.16.0 diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart index ead6d26576f3..b8ba9d5cc854 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -17,6 +17,7 @@ final SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( price: 'price', priceAmountMicros: 1000, priceCurrencyCode: 'priceCurrencyCode', + priceCurrencySymbol: r'$', sku: 'sku', subscriptionPeriod: 'subscriptionPeriod', title: 'title', @@ -139,6 +140,7 @@ Map buildSkuMap(SkuDetailsWrapper original) { 'price': original.price, 'priceAmountMicros': original.priceAmountMicros, 'priceCurrencyCode': original.priceCurrencyCode, + 'priceCurrencySymbol': original.priceCurrencySymbol, 'sku': original.sku, 'subscriptionPeriod': original.subscriptionPeriod, 'title': original.title, diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index 01c73d6ed43e..52ec08bea07a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -108,6 +108,7 @@ void main() { expect(response.productDetails.first.description, dummySkuDetails.description); expect(response.productDetails.first.price, dummySkuDetails.price); + expect(response.productDetails.first.currencySymbol, r'$'); }); test('should get the correct notFoundIDs', () async { From 59e16a556e273c2d69189b2dcdfa92d101ea6408 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 9 Jul 2021 10:01:05 +0200 Subject: [PATCH 123/364] [in_app_purchase] Add iOS currency symbol to ProductDetails (#4144) --- .../in_app_purchase/in_app_purchase_ios/CHANGELOG.md | 4 ++++ .../lib/src/types/app_store_product_details.dart | 5 +++++ .../in_app_purchase/in_app_purchase_ios/pubspec.yaml | 4 ++-- .../test/fakes/fake_ios_platform.dart | 3 +++ .../test/in_app_purchase_ios_platform_test.dart | 2 ++ .../test/store_kit_wrappers/sk_product_test.dart | 4 ++-- .../store_kit_wrappers/sk_test_stub_objects.dart | 12 +++++++++--- 7 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 89d648af5d49..4a2ace891562 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.3 + +* Add price symbol to platform interface object ProductDetail. + ## 0.1.2+2 * Fix crash when retrieveReceiptWithError gives an error. diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart index 96386c5ef5ad..ff1153e27e47 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_product_details.dart @@ -19,6 +19,7 @@ class AppStoreProductDetails extends ProductDetails { required double rawPrice, required String currencyCode, required this.skProduct, + required String currencySymbol, }) : super( id: id, title: title, @@ -26,6 +27,7 @@ class AppStoreProductDetails extends ProductDetails { price: price, rawPrice: rawPrice, currencyCode: currencyCode, + currencySymbol: currencySymbol, ); /// Points back to the [SKProductWrapper] object that was used to generate @@ -41,6 +43,9 @@ class AppStoreProductDetails extends ProductDetails { price: product.priceLocale.currencySymbol + product.price, rawPrice: double.parse(product.price), currencyCode: product.priceLocale.currencyCode, + currencySymbol: product.priceLocale.currencySymbol.isNotEmpty + ? product.priceLocale.currencySymbol + : product.priceLocale.currencyCode, skProduct: product, ); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index c277686fb7c7..89b3ad19bacd 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.2+2 +version: 0.1.3 environment: sdk: ">=2.12.0 <3.0.0" @@ -18,7 +18,7 @@ dependencies: collection: ^1.15.0 flutter: sdk: flutter - in_app_purchase_platform_interface: ^1.0.0 + in_app_purchase_platform_interface: ^1.1.0 json_annotation: ^4.0.1 meta: ^1.3.0 test: ^1.16.0 diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart index ac5c499768a1..9797dba59684 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart @@ -40,6 +40,9 @@ class FakeIOSPlatform { Map productWrapperMap = buildProductMap(dummyProductWrapper); productWrapperMap['productIdentifier'] = validID; + if (validID == '456') { + productWrapperMap['priceLocale'] = buildLocaleMap(noSymbolLocale); + } validProducts[validID] = SKProductWrapper.fromJson(productWrapperMap); } diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart index 973b9d1da0fb..865468f532bf 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart @@ -49,6 +49,8 @@ void main() { expect(products[1].id, '456'); expect(response.notFoundIDs, ['789']); expect(response.error, isNull); + expect(response.productDetails.first.currencySymbol, r'$'); + expect(response.productDetails[1].currencySymbol, 'EUR'); }); test( diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart index 6233a71be135..6a33b75d9808 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_product_test.dart @@ -119,8 +119,8 @@ void main() { test('LocaleWrapper should have property values consistent with map', () { final SKPriceLocaleWrapper wrapper = - SKPriceLocaleWrapper.fromJson(buildLocaleMap(dummyLocale)); - expect(wrapper, equals(dummyLocale)); + SKPriceLocaleWrapper.fromJson(buildLocaleMap(dollarLocale)); + expect(wrapper, equals(dollarLocale)); }); }); diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart index 435dd44fdd8e..595a074f1cfe 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_test_stub_objects.dart @@ -33,12 +33,18 @@ final SKPaymentTransactionWrapper dummyTransaction = error: dummyError, ); -final SKPriceLocaleWrapper dummyLocale = SKPriceLocaleWrapper( +final SKPriceLocaleWrapper dollarLocale = SKPriceLocaleWrapper( currencySymbol: '\$', currencyCode: 'USD', countryCode: 'US', ); +final SKPriceLocaleWrapper noSymbolLocale = SKPriceLocaleWrapper( + currencySymbol: '', + currencyCode: 'EUR', + countryCode: 'UK', +); + final SKProductSubscriptionPeriodWrapper dummySubscription = SKProductSubscriptionPeriodWrapper( numberOfUnits: 1, @@ -47,7 +53,7 @@ final SKProductSubscriptionPeriodWrapper dummySubscription = final SKProductDiscountWrapper dummyDiscount = SKProductDiscountWrapper( price: '1.0', - priceLocale: dummyLocale, + priceLocale: dollarLocale, numberOfPeriods: 1, paymentMode: SKProductDiscountPaymentMode.payUpFront, subscriptionPeriod: dummySubscription, @@ -57,7 +63,7 @@ final SKProductWrapper dummyProductWrapper = SKProductWrapper( productIdentifier: 'id', localizedTitle: 'title', localizedDescription: 'description', - priceLocale: dummyLocale, + priceLocale: dollarLocale, subscriptionGroupIdentifier: 'com.group', price: '1.0', subscriptionPeriod: dummySubscription, From 6065bd001da11de3d1268ded7ba2c253a3924100 Mon Sep 17 00:00:00 2001 From: Abhishek Ghaskata Date: Fri, 9 Jul 2021 22:46:04 +0530 Subject: [PATCH 124/364] [quick_actions] Add const constructor (#4131) --- packages/quick_actions/quick_actions/CHANGELOG.md | 4 ++++ .../quick_actions/lib/quick_actions.dart | 3 +++ packages/quick_actions/quick_actions/pubspec.yaml | 2 +- .../quick_actions/test/quick_actions_test.dart | 11 ++++++++--- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index 179496476c42..b917dcc85db0 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0+3 + +* Added a `const` constructor for the `QuickActions` class, so the plugin will behave as documented in the sample code mentioned in the [README.md](https://github.com/flutter/plugins/blob/59e16a556e273c2d69189b2dcdfa92d101ea6408/packages/quick_actions/quick_actions/README.md). + ## 0.6.0+2 * Migrate maven repository from jcenter to mavenCentral. diff --git a/packages/quick_actions/quick_actions/lib/quick_actions.dart b/packages/quick_actions/quick_actions/lib/quick_actions.dart index f90a44e0443d..6907f25729ab 100644 --- a/packages/quick_actions/quick_actions/lib/quick_actions.dart +++ b/packages/quick_actions/quick_actions/lib/quick_actions.dart @@ -11,6 +11,9 @@ export 'package:quick_actions_platform_interface/types/types.dart'; /// Quick actions plugin. class QuickActions { + /// Creates a new instance of [QuickActions]. + const QuickActions(); + /// Initializes this plugin. /// /// Call this once before any further interaction with the the plugin. diff --git a/packages/quick_actions/quick_actions/pubspec.yaml b/packages/quick_actions/quick_actions/pubspec.yaml index 7927fcc3b548..2a4fb0c634e0 100644 --- a/packages/quick_actions/quick_actions/pubspec.yaml +++ b/packages/quick_actions/quick_actions/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for creating shortcuts on home screen, also known as Quick Actions on iOS and App Shortcuts on Android. repository: https://github.com/flutter/plugins/tree/master/packages/quick_actions issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+quick_actions%22 -version: 0.6.0+2 +version: 0.6.0+3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/quick_actions/quick_actions/test/quick_actions_test.dart b/packages/quick_actions/quick_actions/test/quick_actions_test.dart index b8d7695735b6..27d3c81a809a 100644 --- a/packages/quick_actions/quick_actions/test/quick_actions_test.dart +++ b/packages/quick_actions/quick_actions/test/quick_actions_test.dart @@ -16,8 +16,13 @@ void main() { QuickActionsPlatform.instance = MockQuickActionsPlatform(); }); + test('constructor() should return valid QuickActions instance', () { + const QuickActions quickActions = QuickActions(); + expect(quickActions, isNotNull); + }); + test('initialize() PlatformInterface', () async { - QuickActions quickActions = QuickActions(); + const QuickActions quickActions = QuickActions(); QuickActionHandler handler = (type) {}; await quickActions.initialize(handler); @@ -25,7 +30,7 @@ void main() { }); test('setShortcutItems() PlatformInterface', () { - QuickActions quickActions = QuickActions(); + const QuickActions quickActions = QuickActions(); QuickActionHandler handler = (type) {}; quickActions.initialize(handler); quickActions.setShortcutItems([]); @@ -35,7 +40,7 @@ void main() { }); test('clearShortcutItems() PlatformInterface', () { - QuickActions quickActions = QuickActions(); + const QuickActions quickActions = QuickActions(); QuickActionHandler handler = (type) {}; quickActions.initialize(handler); From 8a966ebfdca42d625d7e1050e447011dbd3f12be Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Fri, 9 Jul 2021 14:26:04 -0700 Subject: [PATCH 125/364] [various] Prepare plugin repo for binding API improvements (#4148) --- packages/camera/camera/CHANGELOG.md | 5 +++++ packages/camera/camera/example/lib/main.dart | 2 +- packages/camera/camera/pubspec.yaml | 2 +- .../video_player/test/video_player_test.dart | 13 +++++++++++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 2cab8e123ae6..1f30104218e3 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.8.1+4 + +* Silenced warnings that may occur during build when using a very + recent version of Flutter relating to null safety. + ## 0.8.1+3 * Do not change camera orientation when iOS device is flat. diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 16d585db9308..00ac2251ba2a 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -98,7 +98,7 @@ class _CameraExampleHomeState extends State @override void dispose() { - WidgetsBinding.instance?.removeObserver(this); + _ambiguate(WidgetsBinding.instance)?.removeObserver(this); _flashModeControlRowAnimationController.dispose(); _exposureModeControlRowAnimationController.dispose(); super.dispose(); diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index a7df9e0d51be..789910e2c79b 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.8.1+3 +version: 0.8.1+4 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index 63498c4e18cb..b5bfad605620 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -856,7 +856,16 @@ class FakeEventsChannel { } void _sendMessage(ByteData data) { - ServicesBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - eventsMethodChannel.name, data, (ByteData? data) {}); + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage( + eventsMethodChannel.name, data, (ByteData? data) {}); } } + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; From 731b93d52e792e782e47d3bdcd237cd2138e56ad Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Sat, 10 Jul 2021 01:10:22 +0200 Subject: [PATCH 126/364] [image_picker_for_web] Migrate image_picker to package:cross_file (#4083) * Migrate image picker platform interface to cross_file * Port tests from --platform=chrome to integration_test Co-authored-by: David Iglesias Teixeira --- .cirrus.yml | 2 +- .../image_picker_for_web/CHANGELOG.md | 5 ++ .../image_picker_for_web/example/README.md | 9 ++ .../image_picker_for_web_test.dart | 34 ++++++-- .../example/lib/main.dart | 25 ++++++ .../image_picker_for_web/example/pubspec.yaml | 21 +++++ .../image_picker_for_web/example/run_test.sh | 22 +++++ .../example/test_driver/integration_test.dart | 7 ++ .../example/web/index.html | 13 +++ .../lib/image_picker_for_web.dart | 82 +++++++++++++++++++ .../image_picker_for_web/pubspec.yaml | 4 +- .../image_picker_for_web/test/README.md | 5 ++ .../test/tests_exist_elsewhere_test.dart | 14 ++++ 13 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 packages/image_picker/image_picker_for_web/example/README.md rename packages/image_picker/image_picker_for_web/{test => example/integration_test}/image_picker_for_web_test.dart (68%) create mode 100644 packages/image_picker/image_picker_for_web/example/lib/main.dart create mode 100644 packages/image_picker/image_picker_for_web/example/pubspec.yaml create mode 100755 packages/image_picker/image_picker_for_web/example/run_test.sh create mode 100644 packages/image_picker/image_picker_for_web/example/test_driver/integration_test.dart create mode 100644 packages/image_picker/image_picker_for_web/example/web/index.html create mode 100644 packages/image_picker/image_picker_for_web/test/README.md create mode 100644 packages/image_picker/image_picker_for_web/test/tests_exist_elsewhere_test.dart diff --git a/.cirrus.yml b/.cirrus.yml index 4ad5e8e03a25..8f69bd188c06 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -168,7 +168,7 @@ task: env: # Currently missing; see https://github.com/flutter/flutter/issues/81982 # and https://github.com/flutter/flutter/issues/82211 - PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "file_selector,image_picker_for_web,shared_preferences_web" + PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "file_selector,shared_preferences_web" matrix: CHANNEL: "master" CHANNEL: "stable" diff --git a/packages/image_picker/image_picker_for_web/CHANGELOG.md b/packages/image_picker/image_picker_for_web/CHANGELOG.md index 7b2c4077e28d..b0379ad2c07c 100644 --- a/packages/image_picker/image_picker_for_web/CHANGELOG.md +++ b/packages/image_picker/image_picker_for_web/CHANGELOG.md @@ -1,3 +1,8 @@ +# 2.1.0 + +* Implemented `getImage`, `getVideo` and `getFile` methods that return `XFile` instances. +* Move tests to `example` directory, so they run as integration_tests with `flutter drive`. + # 2.0.0 * Migrate to null safety. diff --git a/packages/image_picker/image_picker_for_web/example/README.md b/packages/image_picker/image_picker_for_web/example/README.md new file mode 100644 index 000000000000..4348451b14e2 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/README.md @@ -0,0 +1,9 @@ +# Testing + +This package uses `package:integration_test` to run its tests in a web browser. + +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. + +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. diff --git a/packages/image_picker/image_picker_for_web/test/image_picker_for_web_test.dart b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart similarity index 68% rename from packages/image_picker/image_picker_for_web/test/image_picker_for_web_test.dart rename to packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart index fbdd1d38bee6..c6d0b3b532ca 100644 --- a/packages/image_picker/image_picker_for_web/test/image_picker_for_web_test.dart +++ b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -@TestOn('chrome') // Uses dart:html - import 'dart:convert'; import 'dart:html' as html; import 'dart:typed_data'; @@ -11,12 +9,15 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker_for_web/image_picker_for_web.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; final String expectedStringContents = "Hello, world!"; final Uint8List bytes = utf8.encode(expectedStringContents) as Uint8List; final html.File textFile = html.File([bytes], "hello.txt"); void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // Under test... late ImagePickerPlugin plugin; @@ -24,7 +25,7 @@ void main() { plugin = ImagePickerPlugin(); }); - test('Can select a file', () async { + testWidgets('Can select a file (Deprecated)', (WidgetTester tester) async { final mockInput = html.FileUploadInputElement(); final overrides = ImagePickerPluginTestOverrides() @@ -45,9 +46,30 @@ void main() { expect((await file).readAsBytes(), completion(isNotEmpty)); }); + testWidgets('Can select a file', (WidgetTester tester) async { + final mockInput = html.FileUploadInputElement(); + + final overrides = ImagePickerPluginTestOverrides() + ..createInputElement = ((_, __) => mockInput) + ..getFileFromInput = ((_) => textFile); + + final plugin = ImagePickerPlugin(overrides: overrides); + + // Init the pick file dialog... + final file = plugin.getFile(); + + // Mock the browser behavior of selecting a file... + mockInput.dispatchEvent(html.Event('change')); + + // Now the file should be available + expect(file, completes); + // And readable + expect((await file).readAsBytes(), completion(isNotEmpty)); + }); + // There's no good way of detecting when the user has "aborted" the selection. - test('computeCaptureAttribute', () { + testWidgets('computeCaptureAttribute', (WidgetTester tester) async { expect( plugin.computeCaptureAttribute(ImageSource.gallery, CameraDevice.front), isNull, @@ -67,14 +89,14 @@ void main() { }); group('createInputElement', () { - test('accept: any, capture: null', () { + testWidgets('accept: any, capture: null', (WidgetTester tester) async { html.Element input = plugin.createInputElement('any', null); expect(input.attributes, containsPair('accept', 'any')); expect(input.attributes, isNot(contains('capture'))); }); - test('accept: any, capture: something', () { + testWidgets('accept: any, capture: something', (WidgetTester tester) async { html.Element input = plugin.createInputElement('any', 'something'); expect(input.attributes, containsPair('accept', 'any')); diff --git a/packages/image_picker/image_picker_for_web/example/lib/main.dart b/packages/image_picker/image_picker_for_web/example/lib/main.dart new file mode 100644 index 000000000000..e1a38dcdcd46 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/lib/main.dart @@ -0,0 +1,25 @@ +// Copyright 2013 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 'package:flutter/material.dart'; + +void main() { + runApp(MyApp()); +} + +/// App for testing +class MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Text('Testing... Look at the console output for results!'), + ); + } +} diff --git a/packages/image_picker/image_picker_for_web/example/pubspec.yaml b/packages/image_picker/image_picker_for_web/example/pubspec.yaml new file mode 100644 index 000000000000..8dadde267e8a --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: connectivity_for_web_integration_tests +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.2.0" + +dependencies: + image_picker_for_web: + path: ../ + flutter: + sdk: flutter + +dev_dependencies: + js: ^0.6.3 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter diff --git a/packages/image_picker/image_picker_for_web/example/run_test.sh b/packages/image_picker/image_picker_for_web/example/run_test.sh new file mode 100755 index 000000000000..aa52974f310e --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/run_test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/bash +# Copyright 2013 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. + +if pgrep -lf chromedriver > /dev/null; then + echo "chromedriver is running." + + if [ $# -eq 0 ]; then + echo "No target specified, running all tests..." + find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + else + echo "Running test target: $1..." + set -x + flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target=$1 + fi + + else + echo "chromedriver is not running." + echo "Please, check the README.md for instructions on how to use run_test.sh" +fi + diff --git a/packages/image_picker/image_picker_for_web/example/test_driver/integration_test.dart b/packages/image_picker/image_picker_for_web/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/image_picker/image_picker_for_web/example/web/index.html b/packages/image_picker/image_picker_for_web/example/web/index.html new file mode 100644 index 000000000000..7fb138cc90fa --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/web/index.html @@ -0,0 +1,13 @@ + + + + + + example + + + + + diff --git a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart index 2fb66380e1d8..08ce801cafbe 100644 --- a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart +++ b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart @@ -96,6 +96,68 @@ class ImagePickerPlugin extends ImagePickerPlatform { return _getSelectedFile(input); } + /// Returns an [XFile] with the image that was picked. + /// + /// The `source` argument controls where the image comes from. This can + /// be either [ImageSource.camera] or [ImageSource.gallery]. + /// + /// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin. + /// + /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. + /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. + /// Defaults to [CameraDevice.rear]. + /// + /// If no images were picked, the return value is null. + @override + Future getImage({ + required ImageSource source, + double? maxWidth, + double? maxHeight, + int? imageQuality, + CameraDevice preferredCameraDevice = CameraDevice.rear, + }) { + String? capture = computeCaptureAttribute(source, preferredCameraDevice); + return getFile(accept: _kAcceptImageMimeType, capture: capture); + } + + /// Returns an [XFile] containing the video that was picked. + /// + /// The [source] argument controls where the video comes from. This can + /// be either [ImageSource.camera] or [ImageSource.gallery]. + /// + /// Note that the `maxDuration` argument is not supported on the web. If the argument is supplied, it'll be silently ignored by the web version of the plugin. + /// + /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. + /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. + /// Defaults to [CameraDevice.rear]. + /// + /// If no images were picked, the return value is null. + @override + Future getVideo({ + required ImageSource source, + CameraDevice preferredCameraDevice = CameraDevice.rear, + Duration? maxDuration, + }) { + String? capture = computeCaptureAttribute(source, preferredCameraDevice); + return getFile(accept: _kAcceptVideoMimeType, capture: capture); + } + + /// Injects a file input with the specified accept+capture attributes, and + /// returns the PickedFile that the user selected locally. + /// + /// `capture` is only supported in mobile browsers. + /// See https://caniuse.com/#feat=html-media-capture + @visibleForTesting + Future getFile({ + String? accept, + String? capture, + }) { + html.FileUploadInputElement input = + createInputElement(accept, capture) as html.FileUploadInputElement; + _injectAndActivate(input); + return _getSelectedXFile(input); + } + // DOM methods /// Converts plugin configuration into a proper value for the `capture` attribute. @@ -150,6 +212,26 @@ class ImagePickerPlugin extends ImagePickerPlatform { return _completer.future; } + Future _getSelectedXFile(html.FileUploadInputElement input) { + final Completer _completer = Completer(); + // Observe the input until we can return something + input.onChange.first.then((event) { + final objectUrl = _handleOnChangeEvent(event); + if (!_completer.isCompleted && objectUrl != null) { + _completer.complete(XFile(objectUrl)); + } + }); + input.onError.first.then((event) { + if (!_completer.isCompleted) { + _completer.completeError(event); + } + }); + // Note that we don't bother detaching from these streams, since the + // "input" gets re-created in the DOM every time the user needs to + // pick a file. + return _completer.future; + } + /// Initializes a DOM container where we can host input elements. html.Element _ensureInitialized(String id) { var target = html.querySelector('#${id}'); diff --git a/packages/image_picker/image_picker_for_web/pubspec.yaml b/packages/image_picker/image_picker_for_web/pubspec.yaml index 768f7e27ce77..d9b9c5e5cb86 100644 --- a/packages/image_picker/image_picker_for_web/pubspec.yaml +++ b/packages/image_picker/image_picker_for_web/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_for_web description: Web platform implementation of image_picker repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker_for_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 2.0.0 +version: 2.1.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -20,7 +20,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - image_picker_platform_interface: ^2.0.0 + image_picker_platform_interface: ^2.2.0 meta: ^1.3.0 dev_dependencies: diff --git a/packages/image_picker/image_picker_for_web/test/README.md b/packages/image_picker/image_picker_for_web/test/README.md new file mode 100644 index 000000000000..7c5b4ad682ba --- /dev/null +++ b/packages/image_picker/image_picker_for_web/test/README.md @@ -0,0 +1,5 @@ +## test + +This package uses integration tests for testing. + +See `example/README.md` for more info. diff --git a/packages/image_picker/image_picker_for_web/test/tests_exist_elsewhere_test.dart b/packages/image_picker/image_picker_for_web/test/tests_exist_elsewhere_test.dart new file mode 100644 index 000000000000..442c50144727 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/test/tests_exist_elsewhere_test.dart @@ -0,0 +1,14 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Tell the user where to find the real tests', () { + print('---'); + print('This package uses integration_test for its tests.'); + print('See `example/README.md` for more info.'); + print('---'); + }); +} From 1199f131d4458cc6ee68465caf8a956cf70386fc Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 9 Jul 2021 16:38:13 -0700 Subject: [PATCH 127/364] [flutter_plugin_tools] Make unit tests pass on Windows (#4149) The purpose of this PR is to make running all unit tests on Windows pass (vs failing a large portion of the tests as currently happens). This does not mean that the commands actually work when run on Windows, or that Windows support is tested, only that it's possible to actually run the tests themselves. This is prep for actually supporting parts of the tool on Windows in future PRs. Major changes: - Make the tests significantly more hermetic: - Make almost all tools take a `Platform` constructor argument that can be used to inject a mock platform to control what OS the command acts like it is running on under test. - Add a path `Context` object to the base command, whose style matches the `Platform`, and use that almost everywhere instead of the top-level `path` functions. - In cases where Posix behavior is always required (such as parsing `git` output), explicitly use the `posix` context object for `path` functions. - Start laying the groundwork for actual Windows support: - Replace all uses of `flutter` as a command with a getter that returns `flutter` or `flutter.bat` as appropriate. - For user messages that include relative paths, use a helper that always uses Posix-style relative paths for consistent output. This bumps the version since quite a few changes have built up, and having a cut point before starting to make more changes to the commands to support Windows seems like a good idea. Part of https://github.com/flutter/flutter/issues/86113 --- script/tool/lib/src/analyze_command.dart | 13 +- .../tool/lib/src/build_examples_command.dart | 7 +- .../src/common/package_looping_command.dart | 19 ++- .../tool/lib/src/common/plugin_command.dart | 18 +- .../src/create_all_plugins_app_command.dart | 22 ++- .../tool/lib/src/drive_examples_command.dart | 18 +- .../lib/src/firebase_test_lab_command.dart | 14 +- script/tool/lib/src/format_command.dart | 22 ++- script/tool/lib/src/java_test_command.dart | 7 +- .../tool/lib/src/license_check_command.dart | 4 +- .../tool/lib/src/lint_podspecs_command.dart | 12 +- script/tool/lib/src/list_command.dart | 6 +- .../tool/lib/src/publish_check_command.dart | 6 +- .../tool/lib/src/publish_plugin_command.dart | 8 +- .../tool/lib/src/pubspec_check_command.dart | 9 +- script/tool/lib/src/test_command.dart | 4 +- .../tool/lib/src/version_check_command.dart | 18 +- script/tool/lib/src/xctest_command.dart | 7 +- script/tool/test/analyze_command_test.dart | 9 +- .../test/build_examples_command_test.dart | 85 +++++----- .../common/package_looping_command_test.dart | 37 ++++- .../tool/test/common/plugin_command_test.dart | 9 +- .../test/drive_examples_command_test.dart | 155 ++++++++---------- .../test/firebase_test_lab_command_test.dart | 9 +- script/tool/test/format_command_test.dart | 15 +- script/tool/test/java_test_command_test.dart | 24 ++- .../tool/test/lint_podspecs_command_test.dart | 17 +- script/tool/test/list_command_test.dart | 6 +- script/tool/test/mocks.dart | 17 +- .../tool/test/publish_check_command_test.dart | 19 ++- .../test/publish_plugin_command_test.dart | 3 +- .../tool/test/pubspec_check_command_test.dart | 10 +- script/tool/test/test_command_test.dart | 32 ++-- script/tool/test/util.dart | 6 + .../tool/test/version_check_command_test.dart | 5 +- script/tool/test/xctest_command_test.dart | 6 +- 36 files changed, 428 insertions(+), 250 deletions(-) diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 2c4fc1b8376e..e56b95d88eb0 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:file/file.dart'; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -19,7 +19,8 @@ class AnalyzeCommand extends PackageLoopingCommand { AnalyzeCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addMultiOption(_customAnalysisFlag, help: 'Directories (comma separated) that are allowed to have their own analysis options.', @@ -57,9 +58,8 @@ class AnalyzeCommand extends PackageLoopingCommand { final bool allowed = (getStringListArg(_customAnalysisFlag)).any( (String directory) => - directory != null && directory.isNotEmpty && - p.isWithin( + path.isWithin( packagesDir.childDirectory(directory).path, file.path)); if (allowed) { continue; @@ -90,7 +90,7 @@ class AnalyzeCommand extends PackageLoopingCommand { }); for (final Directory package in packageDirectories) { final int exitCode = await processRunner.runAndStream( - 'flutter', ['packages', 'get'], + flutterCommand, ['packages', 'get'], workingDir: package); if (exitCode != 0) { return false; @@ -109,7 +109,8 @@ class AnalyzeCommand extends PackageLoopingCommand { // Use the Dart SDK override if one was passed in. final String? dartSdk = argResults![_analysisSdk] as String?; - _dartBinaryPath = dartSdk == null ? 'dart' : p.join(dartSdk, 'bin', 'dart'); + _dartBinaryPath = + dartSdk == null ? 'dart' : path.join(dartSdk, 'bin', 'dart'); } @override diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 32905c83db91..0cac09980c94 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:file/file.dart'; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -23,7 +23,8 @@ class BuildExamplesCommand extends PackageLoopingCommand { BuildExamplesCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addFlag(kPlatformLinux); argParser.addFlag(kPlatformMacos); argParser.addFlag(kPlatformWeb); @@ -127,7 +128,7 @@ class BuildExamplesCommand extends PackageLoopingCommand { for (final Directory example in getExamplesForPlugin(package)) { final String packageName = - p.relative(example.path, from: packagesDir.path); + getRelativePosixPath(example, from: packagesDir); for (final _PlatformDetails platform in buildPlatforms) { String buildPlatform = platform.label; diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index de1e3b861f59..9f4039ec7074 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -8,6 +8,7 @@ import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'core.dart'; import 'plugin_command.dart'; @@ -63,8 +64,10 @@ abstract class PackageLoopingCommand extends PluginCommand { PackageLoopingCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), GitDir? gitDir, - }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + }) : super(packagesDir, + processRunner: processRunner, platform: platform, gitDir: gitDir); /// Packages that had at least one [logWarning] call. final Set _packagesWithWarnings = {}; @@ -158,8 +161,8 @@ abstract class PackageLoopingCommand extends PluginCommand { /// an exact format (e.g., published name, or basename) is required, that /// should be used instead. String getPackageDescription(Directory package) { - String packageName = p.relative(package.path, from: packagesDir.path); - final List components = p.split(packageName); + String packageName = getRelativePosixPath(package, from: packagesDir); + final List components = p.posix.split(packageName); // For the common federated plugin pattern of `foo/foo_subpackage`, drop // the first part since it's not useful. if (components.length == 2 && @@ -169,6 +172,16 @@ abstract class PackageLoopingCommand extends PluginCommand { return packageName; } + /// Returns the relative path from [from] to [entity] in Posix style. + /// + /// This should be used when, for example, printing package-relative paths in + /// status or error messages. + String getRelativePosixPath( + FileSystemEntity entity, { + required Directory from, + }) => + p.posix.joinAll(path.split(path.relative(entity.path, from: from.path))); + /// The suggested indentation for printed output. String get indentation => hasLongOutput ? '' : ' '; diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 74f607dde7cf..ecdcb0565d35 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -21,6 +21,7 @@ abstract class PluginCommand extends Command { PluginCommand( this.packagesDir, { this.processRunner = const ProcessRunner(), + this.platform = const LocalPlatform(), GitDir? gitDir, }) : _gitDir = gitDir { argParser.addMultiOption( @@ -79,6 +80,11 @@ abstract class PluginCommand extends Command { /// This can be overridden for testing. final ProcessRunner processRunner; + /// The current platform. + /// + /// This can be overridden for testing. + final Platform platform; + /// The git directory to use. If unset, [gitDir] populates it from the /// packages directory's enclosing repository. /// @@ -88,9 +94,11 @@ abstract class PluginCommand extends Command { int? _shardIndex; int? _shardCount; + /// A context that matches the default for [platform]. + p.Context get path => platform.isWindows ? p.windows : p.posix; + /// The command to use when running `flutter`. - String get flutterCommand => - const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; + String get flutterCommand => platform.isWindows ? 'flutter.bat' : 'flutter'; /// The shard of the overall command execution that this instance should run. int get shardIndex { @@ -240,9 +248,9 @@ abstract class PluginCommand extends Command { // plugins under 'my_plugin'. Also match if the exact plugin is // passed. final String relativePath = - p.relative(subdir.path, from: dir.path); - final String packageName = p.basename(subdir.path); - final String basenamePath = p.basename(entity.path); + path.relative(subdir.path, from: dir.path); + final String packageName = path.basename(subdir.path); + final String basenamePath = path.basename(entity.path); if (!excludedPlugins.contains(basenamePath) && !excludedPlugins.contains(packageName) && !excludedPlugins.contains(relativePath) && diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index fab41bcf4ec4..ed7014456086 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -5,6 +5,7 @@ import 'dart:io' as io; import 'package:file/file.dart'; +import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -51,7 +52,7 @@ class CreateAllPluginsAppCommand extends PluginCommand { Future _createApp() async { final io.ProcessResult result = io.Process.runSync( - 'flutter', + flutterCommand, [ 'create', '--template=app', @@ -156,7 +157,7 @@ class CreateAllPluginsAppCommand extends PluginCommand { {}; await for (final Directory package in getPlugins()) { - final String pluginName = package.path.split('/').last; + final String pluginName = package.basename; final File pubspecFile = package.childFile('pubspec.yaml'); final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); @@ -172,6 +173,7 @@ class CreateAllPluginsAppCommand extends PluginCommand { ### Generated file. Do not edit. Run `pub global run flutter_plugin_tools gen-pubspec` to update. name: ${pubspec.name} description: ${pubspec.description} +publish_to: none version: ${pubspec.version} @@ -197,7 +199,21 @@ dev_dependencies:${_pubspecMapString(pubspec.devDependencies)} buffer.write(' ${entry.key}: \n sdk: ${dep.sdk}'); } else if (entry.value is PathDependency) { final PathDependency dep = entry.value as PathDependency; - buffer.write(' ${entry.key}: \n path: ${dep.path}'); + String depPath = dep.path; + if (path.style == p.Style.windows) { + // Posix-style path separators are preferred in pubspec.yaml (and + // using a consistent format makes unit testing simpler), so convert. + final List components = path.split(depPath); + final String firstComponent = components.first; + // path.split leaves a \ on drive components that isn't necessary, + // and confuses pub, so remove it. + if (firstComponent.endsWith(r':\')) { + components[0] = + firstComponent.substring(0, firstComponent.length - 1); + } + depPath = p.posix.joinAll(components); + } + buffer.write(' ${entry.key}: \n path: $depPath'); } else { throw UnimplementedError( 'Not available for type: ${entry.value.runtimeType}', diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index df74119e4019..7e800ed54866 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -6,7 +6,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:file/file.dart'; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -22,7 +22,8 @@ class DriveExamplesCommand extends PackageLoopingCommand { DriveExamplesCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addFlag(kPlatformAndroid, help: 'Runs the Android implementation of the examples'); argParser.addFlag(kPlatformIos, @@ -148,7 +149,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { for (final Directory example in getExamplesForPlugin(package)) { ++examplesFound; final String exampleName = - p.relative(example.path, from: packagesDir.path); + getRelativePosixPath(example, from: packagesDir); final List drivers = await _getDrivers(example); if (drivers.isEmpty) { @@ -172,11 +173,10 @@ class DriveExamplesCommand extends PackageLoopingCommand { if (testTargets.isEmpty) { final String driverRelativePath = - p.relative(driver.path, from: package.path); + getRelativePosixPath(driver, from: package); printError( 'Found $driverRelativePath, but no integration_test/*_test.dart files.'); - errors.add( - 'No test files for ${p.relative(driver.path, from: package.path)}'); + errors.add('No test files for $driverRelativePath'); continue; } @@ -185,7 +185,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { example, driver, testTargets, deviceFlags: deviceFlags); for (final File failingTarget in failingTargets) { - errors.add(p.relative(failingTarget.path, from: package.path)); + errors.add(getRelativePosixPath(failingTarget, from: package)); } } } @@ -296,9 +296,9 @@ class DriveExamplesCommand extends PackageLoopingCommand { if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', '--driver', - p.relative(driver.path, from: example.path), + getRelativePosixPath(driver, from: example), '--target', - p.relative(target.path, from: example.path), + getRelativePosixPath(target, from: example), ], workingDir: example); if (exitCode != 0) { diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 8253ceeda86b..5e4d9f080085 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:io' as io; import 'package:file/file.dart'; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'package:uuid/uuid.dart'; import 'common/core.dart'; @@ -21,7 +21,8 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { FirebaseTestLabCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addOption( 'project', defaultsTo: 'flutter-infra', @@ -29,8 +30,9 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { ); final String? homeDir = io.Platform.environment['HOME']; argParser.addOption('service-key', - defaultsTo: - homeDir == null ? null : p.join(homeDir, 'gcloud-service-key.json'), + defaultsTo: homeDir == null + ? null + : path.join(homeDir, 'gcloud-service-key.json'), help: 'The path to the service key for gcloud authentication.\n' r'If not provided, \$HOME/gcloud-service-key.json will be ' r'assumed if $HOME is set.'); @@ -150,7 +152,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { // test file's run. int resultsCounter = 0; for (final File test in _findIntegrationTestFiles(package)) { - final String testName = p.relative(test.path, from: package.path); + final String testName = getRelativePosixPath(test, from: package); print('Testing $testName...'); if (!await _runGradle(androidDirectory, 'app:assembleDebug', testFile: test)) { @@ -203,7 +205,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { print('Running flutter build apk...'); final String experiment = getStringArg(kEnableExperiment); final int exitCode = await processRunner.runAndStream( - 'flutter', + flutterCommand, [ 'build', 'apk', diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index 9d39d93b9118..7954fd044ce4 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -7,7 +7,7 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'package:quiver/iterables.dart'; import 'common/core.dart'; @@ -28,7 +28,8 @@ class FormatCommand extends PluginCommand { FormatCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addFlag('fail-on-change', hide: true); argParser.addOption('clang-format', defaultsTo: 'clang-format', @@ -156,7 +157,7 @@ class FormatCommand extends PluginCommand { // `flutter format` doesn't require the project to actually be a Flutter // project. final int exitCode = await processRunner.runAndStream( - 'flutter', ['format', ...dartFiles], + flutterCommand, ['format', ...dartFiles], workingDir: packagesDir); if (exitCode != 0) { printError('Failed to format Dart files: exit code $exitCode.'); @@ -168,8 +169,12 @@ class FormatCommand extends PluginCommand { Future> _getFilteredFilePaths(Stream files) async { // Returns a pattern to check for [directories] as a subset of a file path. RegExp pathFragmentForDirectories(List directories) { - final String s = p.separator; - return RegExp('(?:^|$s)${p.joinAll(directories)}$s'); + String s = path.separator; + // Escape the separator for use in the regex. + if (s == r'\') { + s = r'\\'; + } + return RegExp('(?:^|$s)${path.joinAll(directories)}$s'); } return files @@ -188,12 +193,13 @@ class FormatCommand extends PluginCommand { Iterable _getPathsWithExtensions( Iterable files, Set extensions) { - return files.where((String path) => extensions.contains(p.extension(path))); + return files.where( + (String filePath) => extensions.contains(path.extension(filePath))); } Future _getGoogleFormatterPath() async { - final String javaFormatterPath = p.join( - p.dirname(p.fromUri(io.Platform.script)), + final String javaFormatterPath = path.join( + path.dirname(path.fromUri(platform.script)), 'google-java-format-1.3-all-deps.jar'); final File javaFormatterFile = packagesDir.fileSystem.file(javaFormatterPath); diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart index 352197be3057..b36d1102f109 100644 --- a/script/tool/lib/src/java_test_command.dart +++ b/script/tool/lib/src/java_test_command.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:file/file.dart'; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -15,7 +15,8 @@ class JavaTestCommand extends PackageLoopingCommand { JavaTestCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner); + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform); static const String _gradleWrapper = 'gradlew'; @@ -50,7 +51,7 @@ class JavaTestCommand extends PackageLoopingCommand { final List errors = []; for (final Directory example in examplesWithTests) { - final String exampleName = p.relative(example.path, from: package.path); + final String exampleName = getRelativePosixPath(example, from: package); print('\nRUNNING JAVA TESTS for $exampleName'); final Directory androidDirectory = example.childDirectory('android'); diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index 1d3e49c6a7c6..093f8143df4f 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -112,7 +112,7 @@ class LicenseCheckCommand extends PluginCommand { !_shouldIgnoreFile(file)); final Iterable firstPartyLicenseFiles = (await _getAllFiles()).where( (File file) => - p.basename(file.basename) == 'LICENSE' && !_isThirdParty(file)); + path.basename(file.basename) == 'LICENSE' && !_isThirdParty(file)); final bool copyrightCheckSucceeded = await _checkCodeLicenses(codeFiles); print('\n=======================================\n'); @@ -246,7 +246,7 @@ class LicenseCheckCommand extends PluginCommand { } bool _isThirdParty(File file) { - return p.split(file.path).contains('third_party'); + return path.split(file.path).contains('third_party'); } Future> _getAllFiles() => packagesDir.parent diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index 82cce0bd13e6..d0d93fcb79b1 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -25,8 +25,7 @@ class LintPodspecsCommand extends PackageLoopingCommand { Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), - }) : _platform = platform, - super(packagesDir, processRunner: processRunner) { + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addMultiOption('ignore-warnings', help: 'Do not pass --allow-warnings flag to "pod lib lint" for podspecs ' @@ -45,11 +44,9 @@ class LintPodspecsCommand extends PackageLoopingCommand { 'Runs "pod lib lint" on all iOS and macOS plugin podspecs.\n\n' 'This command requires "pod" and "flutter" to be in your path. Runs on macOS only.'; - final Platform _platform; - @override Future initializeRun() async { - if (!_platform.isMacOS) { + if (!platform.isMacOS) { printError('This command is only supported on macOS'); throw ToolExit(_exitUnsupportedPlatform); } @@ -89,11 +86,10 @@ class LintPodspecsCommand extends PackageLoopingCommand { final List podspecs = await getFilesForPackage(package).where((File entity) { final String filePath = entity.path; - return p.extension(filePath) == '.podspec'; + return path.extension(filePath) == '.podspec'; }).toList(); - podspecs.sort( - (File a, File b) => p.basename(a.path).compareTo(p.basename(b.path))); + podspecs.sort((File a, File b) => a.basename.compareTo(b.basename)); return podspecs; } diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart index 39515cf686b0..20f01ff98f0e 100644 --- a/script/tool/lib/src/list_command.dart +++ b/script/tool/lib/src/list_command.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:file/file.dart'; +import 'package:platform/platform.dart'; import 'common/plugin_command.dart'; @@ -10,7 +11,10 @@ import 'common/plugin_command.dart'; class ListCommand extends PluginCommand { /// Creates an instance of the list command, whose behavior depends on the /// 'type' argument it provides. - ListCommand(Directory packagesDir) : super(packagesDir) { + ListCommand( + Directory packagesDir, { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, platform: platform) { argParser.addOption( _type, defaultsTo: _plugin, diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index ccafabfddd1d..fda68a6a74a4 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -8,6 +8,7 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:http/http.dart' as http; +import 'package:platform/platform.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -22,10 +23,11 @@ class PublishCheckCommand extends PackageLoopingCommand { PublishCheckCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), http.Client? httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), - super(packagesDir, processRunner: processRunner) { + super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addFlag( _allowPrereleaseFlag, help: 'Allows the pre-release SDK warning to pass.\n' @@ -128,7 +130,7 @@ class PublishCheckCommand extends PackageLoopingCommand { Future _hasValidPublishCheckRun(Directory package) async { print('Running pub publish --dry-run:'); final io.Process process = await processRunner.start( - 'flutter', + flutterCommand, ['pub', 'publish', '--', '--dry-run'], workingDirectory: package, ); diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 6de53ba2690a..8bcb9e37e8ef 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -208,9 +208,13 @@ class PublishPluginCommand extends PluginCommand { final List packagesFailed = []; for (final String pubspecPath in changedPubspecs) { + // Convert git's Posix-style paths to a path that matches the current + // filesystem. + final String localStylePubspecPath = + path.joinAll(p.posix.split(pubspecPath)); final File pubspecFile = packagesDir.fileSystem .directory(baseGitDir.path) - .childFile(pubspecPath); + .childFile(localStylePubspecPath); final _CheckNeedsReleaseResult result = await _checkNeedsRelease( pubspecFile: pubspecFile, existingTags: existingTags, @@ -445,7 +449,7 @@ Safe to ignore if the package is deleted in this commit. } final io.Process publish = await processRunner.start( - 'flutter', ['pub', 'publish'] + publishFlags, + flutterCommand, ['pub', 'publish'] + publishFlags, workingDirectory: packageDir); publish.stdout .transform(utf8.decoder) diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 7d39c7322b71..539b170dbea1 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -4,6 +4,7 @@ import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:platform/platform.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'common/package_looping_command.dart'; @@ -19,8 +20,14 @@ class PubspecCheckCommand extends PackageLoopingCommand { PubspecCheckCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), GitDir? gitDir, - }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + }) : super( + packagesDir, + processRunner: processRunner, + platform: platform, + gitDir: gitDir, + ); // Section order for plugins. Because the 'flutter' section is critical // information for plugins, and usually small, it goes near the top unlike in diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart index d06a2841812a..9dfe66b7926a 100644 --- a/script/tool/lib/src/test_command.dart +++ b/script/tool/lib/src/test_command.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:file/file.dart'; +import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -15,7 +16,8 @@ class TestCommand extends PackageLoopingCommand { TestCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addOption( kEnableExperiment, defaultsTo: '', diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index f0902f016833..c08600c3f669 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -7,6 +7,7 @@ import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -77,11 +78,17 @@ class VersionCheckCommand extends PackageLoopingCommand { VersionCheckCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), GitDir? gitDir, http.Client? httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), - super(packagesDir, processRunner: processRunner, gitDir: gitDir) { + super( + packagesDir, + processRunner: processRunner, + platform: platform, + gitDir: gitDir, + ) { argParser.addFlag( _againstPubFlag, help: 'Whether the version check should run against the version on pub.\n' @@ -179,8 +186,13 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} required GitVersionFinder gitVersionFinder, }) async { final File pubspecFile = package.childFile('pubspec.yaml'); - return await gitVersionFinder.getPackageVersion( - p.relative(pubspecFile.absolute.path, from: (await gitDir).path)); + final String relativePath = + path.relative(pubspecFile.absolute.path, from: (await gitDir).path); + // Use Posix-style paths for git. + final String gitPath = path.style == p.Style.windows + ? p.posix.joinAll(path.split(relativePath)) + : relativePath; + return await gitVersionFinder.getPackageVersion(gitPath); } /// Returns true if the version of [package] is either unchanged relative to diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index cd3b674f8d3a..176adad39a09 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -6,7 +6,7 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; -import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -32,7 +32,8 @@ class XCTestCommand extends PackageLoopingCommand { XCTestCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, processRunner: processRunner) { + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addOption( _kiOSDestination, help: @@ -142,7 +143,7 @@ class XCTestCommand extends PackageLoopingCommand { for (final Directory example in getExamplesForPlugin(plugin)) { // Running tests and static analyzer. final String examplePath = - p.relative(example.path, from: plugin.parent.path); + getRelativePosixPath(example, from: plugin.parent); print('Running $platform tests and analyzer for $examplePath...'); int exitCode = await _runTests(true, example, platform, extraFlags: extraXcrunFlags); diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index adeaabaaca52..69a2c4f95523 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -16,16 +16,21 @@ import 'util.dart'; void main() { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late RecordingProcessRunner processRunner; late CommandRunner runner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final AnalyzeCommand analyzeCommand = - AnalyzeCommand(packagesDir, processRunner: processRunner); + final AnalyzeCommand analyzeCommand = AnalyzeCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner('analyze_command', 'Test for analyze_command'); runner.addCommand(analyzeCommand); diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index c0c90a15c71c..27489a50228a 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -10,8 +10,6 @@ import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/build_examples_command.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; -import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -20,18 +18,21 @@ import 'util.dart'; void main() { group('build-example', () { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; - final String flutterCommand = - const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final BuildExamplesCommand command = - BuildExamplesCommand(packagesDir, processRunner: processRunner); + final BuildExamplesCommand command = BuildExamplesCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner( 'build_examples_command', 'Test for build_example_command'); @@ -59,7 +60,9 @@ void main() { kPlatformIos: PlatformSupport.inline }); - processRunner.mockProcessesForExecutable['flutter'] = [ + processRunner + .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [ MockProcess.failing() // flutter packages get ]; @@ -81,6 +84,7 @@ void main() { test('building for iOS when plugin is not set up for iOS results in no-op', () async { + mockPlatform.isMacOS = true; createFakePlugin('plugin', packagesDir); final List output = @@ -99,7 +103,8 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('building for ios', () async { + test('building for iOS', () async { + mockPlatform.isMacOS = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformIos: PlatformSupport.inline @@ -110,13 +115,11 @@ void main() { final List output = await runCapturingPrint(runner, ['build-examples', '--ios', '--enable-experiment=exp1']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - '\nBUILDING $packageName for iOS', + '\nBUILDING plugin/example for iOS', ]), ); @@ -124,7 +127,7 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, + getFlutterCommand(mockPlatform), const [ 'build', 'ios', @@ -138,6 +141,7 @@ void main() { test( 'building for Linux when plugin is not set up for Linux results in no-op', () async { + mockPlatform.isLinux = true; createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint( @@ -157,6 +161,7 @@ void main() { }); test('building for Linux', () async { + mockPlatform.isLinux = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformLinux: PlatformSupport.inline, @@ -167,26 +172,25 @@ void main() { final List output = await runCapturingPrint( runner, ['build-examples', '--linux']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - '\nBUILDING $packageName for Linux', + '\nBUILDING plugin/example for Linux', ]), ); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(flutterCommand, const ['build', 'linux'], - pluginExampleDirectory.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['build', 'linux'], pluginExampleDirectory.path), ])); }); - test('building for macos with no implementation results in no-op', + test('building for macOS with no implementation results in no-op', () async { + mockPlatform.isMacOS = true; createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint( @@ -205,7 +209,8 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('building for macos', () async { + test('building for macOS', () async { + mockPlatform.isMacOS = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformMacos: PlatformSupport.inline, @@ -216,21 +221,19 @@ void main() { final List output = await runCapturingPrint( runner, ['build-examples', '--macos']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - '\nBUILDING $packageName for macOS', + '\nBUILDING plugin/example for macOS', ]), ); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(flutterCommand, const ['build', 'macos'], - pluginExampleDirectory.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['build', 'macos'], pluginExampleDirectory.path), ])); }); @@ -264,27 +267,26 @@ void main() { final List output = await runCapturingPrint(runner, ['build-examples', '--web']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - '\nBUILDING $packageName for web', + '\nBUILDING plugin/example for web', ]), ); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(flutterCommand, const ['build', 'web'], - pluginExampleDirectory.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['build', 'web'], pluginExampleDirectory.path), ])); }); test( 'building for Windows when plugin is not set up for Windows results in no-op', () async { + mockPlatform.isWindows = true; createFakePlugin('plugin', packagesDir); final List output = await runCapturingPrint( @@ -303,7 +305,8 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('building for windows', () async { + test('building for Windows', () async { + mockPlatform.isWindows = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformWindows: PlatformSupport.inline @@ -314,20 +317,20 @@ void main() { final List output = await runCapturingPrint( runner, ['build-examples', '--windows']); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - '\nBUILDING $packageName for Windows', + '\nBUILDING plugin/example for Windows', ]), ); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(flutterCommand, const ['build', 'windows'], + ProcessCall( + getFlutterCommand(mockPlatform), + const ['build', 'windows'], pluginExampleDirectory.path), ])); }); @@ -353,7 +356,7 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('building for android', () async { + test('building for Android', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformAndroid: PlatformSupport.inline @@ -366,21 +369,19 @@ void main() { 'build-examples', '--apk', ]); - final String packageName = - p.relative(pluginExampleDirectory.path, from: packagesDir.path); expect( output, containsAllInOrder([ - '\nBUILDING $packageName for Android (apk)', + '\nBUILDING plugin/example for Android (apk)', ]), ); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(flutterCommand, const ['build', 'apk'], - pluginExampleDirectory.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['build', 'apk'], pluginExampleDirectory.path), ])); }); @@ -400,7 +401,7 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, + getFlutterCommand(mockPlatform), const ['build', 'apk', '--enable-experiment=exp1'], pluginExampleDirectory.path), ])); @@ -421,7 +422,7 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, + getFlutterCommand(mockPlatform), const [ 'build', 'ios', diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index 917fbc0fd67a..542e91af6431 100644 --- a/script/tool/test/common/package_looping_command_test.dart +++ b/script/tool/test/common/package_looping_command_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:io'; +import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; @@ -13,8 +13,10 @@ import 'package:flutter_plugin_tools/src/common/package_looping_command.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:git/git.dart'; import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; import '../util.dart'; import 'plugin_command_test.mocks.dart'; @@ -36,11 +38,13 @@ const String _warningFile = 'warnings'; void main() { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late Directory thirdPartyPackagesDir; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); thirdPartyPackagesDir = packagesDir.parent .childDirectory('third_party') @@ -69,11 +73,12 @@ void main() { when(mockProcessResult.stdout as String?) .thenReturn(gitDiffResponse); } - return Future.value(mockProcessResult); + return Future.value(mockProcessResult); }); return TestPackageLoopingCommand( packagesDir, + platform: mockPlatform, hasLongOutput: hasLongOutput, includeSubpackages: includeSubpackages, failsDuringInit: failsDuringInit, @@ -502,7 +507,25 @@ void main() { test('getPackageDescription prints packageDir-relative paths by default', () async { final TestPackageLoopingCommand command = - TestPackageLoopingCommand(packagesDir); + TestPackageLoopingCommand(packagesDir, platform: mockPlatform); + + expect( + command.getPackageDescription(packagesDir.childDirectory('foo')), + 'foo', + ); + expect( + command.getPackageDescription(packagesDir + .childDirectory('foo') + .childDirectory('bar') + .childDirectory('baz')), + 'foo/bar/baz', + ); + }); + + test('getPackageDescription always uses Posix-style paths', () async { + mockPlatform.isWindows = true; + final TestPackageLoopingCommand command = + TestPackageLoopingCommand(packagesDir, platform: mockPlatform); expect( command.getPackageDescription(packagesDir.childDirectory('foo')), @@ -521,7 +544,7 @@ void main() { 'getPackageDescription elides group name in grouped federated plugin structure', () async { final TestPackageLoopingCommand command = - TestPackageLoopingCommand(packagesDir); + TestPackageLoopingCommand(packagesDir, platform: mockPlatform); expect( command.getPackageDescription(packagesDir @@ -542,6 +565,7 @@ void main() { class TestPackageLoopingCommand extends PackageLoopingCommand { TestPackageLoopingCommand( Directory packagesDir, { + required Platform platform, this.hasLongOutput = true, this.includeSubpackages = false, this.customFailureListHeader, @@ -552,7 +576,8 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { this.captureOutput = false, ProcessRunner processRunner = const ProcessRunner(), GitDir? gitDir, - }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + }) : super(packagesDir, + processRunner: processRunner, platform: platform, gitDir: gitDir); final List checkedPackages = []; final List capturedOutput = []; @@ -629,4 +654,4 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { } } -class MockProcessResult extends Mock implements ProcessResult {} +class MockProcessResult extends Mock implements io.ProcessResult {} diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 3f1f1adc4c19..fdab9612be3f 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -12,8 +12,10 @@ import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:git/git.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; import '../util.dart'; import 'plugin_command_test.mocks.dart'; @@ -22,6 +24,7 @@ void main() { late RecordingProcessRunner processRunner; late CommandRunner runner; late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late Directory thirdPartyPackagesDir; late List plugins; @@ -30,6 +33,7 @@ void main() { setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); thirdPartyPackagesDir = packagesDir.parent .childDirectory('third_party') @@ -54,6 +58,7 @@ void main() { plugins, packagesDir, processRunner: processRunner, + platform: mockPlatform, gitDir: gitDir, ); runner = @@ -414,8 +419,10 @@ class SamplePluginCommand extends PluginCommand { this._plugins, Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), GitDir? gitDir, - }) : super(packagesDir, processRunner: processRunner, gitDir: gitDir); + }) : super(packagesDir, + processRunner: processRunner, platform: platform, gitDir: gitDir); final List _plugins; diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 681a9e0e5844..c6893181e286 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -10,7 +10,6 @@ import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/drive_examples_command.dart'; -import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:test/test.dart'; @@ -23,18 +22,18 @@ const String _fakeAndroidDevice = 'emulator-1234'; void main() { group('test drive_example_command', () { late FileSystem fileSystem; + late Platform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; - final String flutterCommand = - const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final DriveExamplesCommand command = - DriveExamplesCommand(packagesDir, processRunner: processRunner); + final DriveExamplesCommand command = DriveExamplesCommand(packagesDir, + processRunner: processRunner, platform: mockPlatform); runner = CommandRunner( 'drive_examples_command', 'Test for drive_example_command'); @@ -63,9 +62,9 @@ void main() { final MockProcess mockDevicesProcess = MockProcess.succeeding(); mockDevicesProcess.stdoutController.close(); // ignore: unawaited_futures - processRunner.mockProcessesForExecutable['flutter'] = [ - mockDevicesProcess - ]; + processRunner + .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [mockDevicesProcess]; processRunner.resultStdout = output; } @@ -150,9 +149,9 @@ void main() { test('fails for iOS if getting devices fails', () async { // Simulate failure from `flutter devices`. - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() - ]; + processRunner + .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [MockProcess.failing()]; Error? commandError; final List output = await runCapturingPrint( @@ -216,23 +215,21 @@ void main() { ]), ); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall(getFlutterCommand(mockPlatform), + const ['devices', '--machine'], null), ProcessCall( - flutterCommand, const ['devices', '--machine'], null), - ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', _fakeIosDevice, '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -337,35 +334,33 @@ void main() { ]), ); - final String driverTestPath = - p.join('test_driver', 'integration_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall(getFlutterCommand(mockPlatform), + const ['devices', '--machine'], null), ProcessCall( - flutterCommand, const ['devices', '--machine'], null), - ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', _fakeIosDevice, '--driver', - driverTestPath, + 'test_driver/integration_test.dart', '--target', - p.join('integration_test', 'bar_test.dart'), + 'integration_test/bar_test.dart', ], pluginExampleDirectory.path), ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', _fakeIosDevice, '--driver', - driverTestPath, + 'test_driver/integration_test.dart', '--target', - p.join('integration_test', 'foo_test.dart'), + 'integration_test/foo_test.dart', ], pluginExampleDirectory.path), ])); @@ -425,21 +420,19 @@ void main() { ]), ); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', 'linux', '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -500,21 +493,19 @@ void main() { ]), ); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', 'macos', '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -573,23 +564,21 @@ void main() { ]), ); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', 'web-server', '--web-port=7357', '--browser-name=chrome', '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -649,21 +638,19 @@ void main() { ]), ); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', 'windows', '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -699,23 +686,21 @@ void main() { ]), ); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall(getFlutterCommand(mockPlatform), + const ['devices', '--machine'], null), ProcessCall( - flutterCommand, const ['devices', '--machine'], null), - ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', _fakeAndroidDevice, '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -749,8 +734,8 @@ void main() { // Output should be empty other than the device query. expect(processRunner.recordedCalls, [ - ProcessCall( - flutterCommand, const ['devices', '--machine'], null), + ProcessCall(getFlutterCommand(mockPlatform), + const ['devices', '--machine'], null), ]); }); @@ -782,8 +767,8 @@ void main() { // Output should be empty other than the device query. expect(processRunner.recordedCalls, [ - ProcessCall( - flutterCommand, const ['devices', '--machine'], null), + ProcessCall(getFlutterCommand(mockPlatform), + const ['devices', '--machine'], null), ]); }); @@ -833,24 +818,22 @@ void main() { '--enable-experiment=exp1', ]); - final String deviceTestPath = p.join('test_driver', 'plugin.dart'); - final String driverTestPath = p.join('test_driver', 'plugin_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ + ProcessCall(getFlutterCommand(mockPlatform), + const ['devices', '--machine'], null), ProcessCall( - flutterCommand, const ['devices', '--machine'], null), - ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', _fakeIosDevice, '--enable-experiment=exp1', '--driver', - driverTestPath, + 'test_driver/plugin_test.dart', '--target', - deviceTestPath + 'test_driver/plugin.dart' ], pluginExampleDirectory.path), ])); @@ -967,7 +950,9 @@ void main() { ); // Simulate failure from `flutter drive`. - processRunner.mockProcessesForExecutable['flutter'] = [ + processRunner + .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [ // No mock for 'devices', since it's running for macOS. MockProcess.failing(), // 'drive' #1 MockProcess.failing(), // 'drive' #2 @@ -994,33 +979,31 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final String driverTestPath = - p.join('test_driver', 'integration_test.dart'); expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', 'macos', '--driver', - driverTestPath, + 'test_driver/integration_test.dart', '--target', - p.join('integration_test', 'bar_test.dart'), + 'integration_test/bar_test.dart', ], pluginExampleDirectory.path), ProcessCall( - flutterCommand, - [ + getFlutterCommand(mockPlatform), + const [ 'drive', '-d', 'macos', '--driver', - driverTestPath, + 'test_driver/integration_test.dart', '--target', - p.join('integration_test', 'foo_test.dart'), + 'integration_test/foo_test.dart', ], pluginExampleDirectory.path), ])); diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index 0199eba95983..c265868bbf3e 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -17,16 +17,21 @@ import 'util.dart'; void main() { group('$FirebaseTestLabCommand', () { FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final FirebaseTestLabCommand command = - FirebaseTestLabCommand(packagesDir, processRunner: processRunner); + final FirebaseTestLabCommand command = FirebaseTestLabCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner( 'firebase_test_lab_command', 'Test for $FirebaseTestLabCommand'); diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index e7f4d795eb93..fabef31a1b64 100644 --- a/script/tool/test/format_command_test.dart +++ b/script/tool/test/format_command_test.dart @@ -17,21 +17,28 @@ import 'util.dart'; void main() { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; + late p.Context path; late RecordingProcessRunner processRunner; late CommandRunner runner; late String javaFormatPath; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final FormatCommand analyzeCommand = - FormatCommand(packagesDir, processRunner: processRunner); + final FormatCommand analyzeCommand = FormatCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); // Create the java formatter file that the command checks for, to avoid // a download. - javaFormatPath = p.join(p.dirname(p.fromUri(io.Platform.script)), + path = analyzeCommand.path; + javaFormatPath = path.join(path.dirname(path.fromUri(mockPlatform.script)), 'google-java-format-1.3-all-deps.jar'); fileSystem.file(javaFormatPath).createSync(recursive: true); @@ -42,7 +49,7 @@ void main() { List _getAbsolutePaths( Directory package, List relativePaths) { return relativePaths - .map((String path) => p.join(package.path, path)) + .map((String relativePath) => path.join(package.path, relativePath)) .toList(); } diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart index 9ae959710984..13e0e7fc0f40 100644 --- a/script/tool/test/java_test_command_test.dart +++ b/script/tool/test/java_test_command_test.dart @@ -10,7 +10,6 @@ import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/java_test_command.dart'; -import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'mocks.dart'; @@ -19,16 +18,21 @@ import 'util.dart'; void main() { group('$JavaTestCommand', () { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final JavaTestCommand command = - JavaTestCommand(packagesDir, processRunner: processRunner); + final JavaTestCommand command = JavaTestCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner('java_test_test', 'Test for $JavaTestCommand'); @@ -50,13 +54,16 @@ void main() { await runCapturingPrint(runner, ['java-test']); + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - p.join(plugin.path, 'example/android/gradlew'), + androidFolder.childFile('gradlew').path, const ['testDebugUnitTest', '--info'], - p.join(plugin.path, 'example/android'), + androidFolder.path, ), ]), ); @@ -77,13 +84,16 @@ void main() { await runCapturingPrint(runner, ['java-test']); + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( - p.join(plugin.path, 'example/android/gradlew'), + androidFolder.childFile('gradlew').path, const ['testDebugUnitTest', '--info'], - p.join(plugin.path, 'example/android'), + androidFolder.path, ), ]), ); diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 1236ec0f5013..51a4e6267770 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -9,7 +9,6 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/lint_podspecs_command.dart'; -import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'mocks.dart'; @@ -24,7 +23,7 @@ void main() { late RecordingProcessRunner processRunner; setUp(() { - fileSystem = MemoryFileSystem(); + fileSystem = MemoryFileSystem(style: FileSystemStyle.posix); packagesDir = createPackagesDirectory(fileSystem: fileSystem); mockPlatform = MockPlatform(isMacOS: true); @@ -94,7 +93,10 @@ void main() { [ 'lib', 'lint', - p.join(plugin1Dir.path, 'ios', 'plugin1.podspec'), + plugin1Dir + .childDirectory('ios') + .childFile('plugin1.podspec') + .path, '--configuration=Debug', '--skip-tests', '--use-modular-headers', @@ -106,7 +108,10 @@ void main() { [ 'lib', 'lint', - p.join(plugin1Dir.path, 'ios', 'plugin1.podspec'), + plugin1Dir + .childDirectory('ios') + .childFile('plugin1.podspec') + .path, '--configuration=Debug', '--skip-tests', '--use-modular-headers', @@ -136,7 +141,7 @@ void main() { [ 'lib', 'lint', - p.join(plugin1Dir.path, 'plugin1.podspec'), + plugin1Dir.childFile('plugin1.podspec').path, '--configuration=Debug', '--skip-tests', '--use-modular-headers', @@ -149,7 +154,7 @@ void main() { [ 'lib', 'lint', - p.join(plugin1Dir.path, 'plugin1.podspec'), + plugin1Dir.childFile('plugin1.podspec').path, '--configuration=Debug', '--skip-tests', '--use-modular-headers', diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart index 836d06671c24..488fc9bcb1e4 100644 --- a/script/tool/test/list_command_test.dart +++ b/script/tool/test/list_command_test.dart @@ -8,18 +8,22 @@ import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/list_command.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; void main() { group('$ListCommand', () { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); - final ListCommand command = ListCommand(packagesDir); + final ListCommand command = + ListCommand(packagesDir, platform: mockPlatform); runner = CommandRunner('list_test', 'Test for $ListCommand'); runner.addCommand(command); diff --git a/script/tool/test/mocks.dart b/script/tool/test/mocks.dart index 02b00658398e..0dcdedd3db03 100644 --- a/script/tool/test/mocks.dart +++ b/script/tool/test/mocks.dart @@ -10,10 +10,25 @@ import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; class MockPlatform extends Mock implements Platform { - MockPlatform({this.isMacOS = false}); + MockPlatform({ + this.isLinux = false, + this.isMacOS = false, + this.isWindows = false, + }); + + @override + bool isLinux; @override bool isMacOS; + + @override + bool isWindows; + + @override + Uri get script => isWindows + ? Uri.file(r'C:\foo\bar', windows: true) + : Uri.file('/foo/bar', windows: false); } class MockProcess extends Mock implements io.Process { diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 5140316b4511..11de9f095481 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -21,16 +21,21 @@ import 'util.dart'; void main() { group('$PublishCheckProcessRunner tests', () { FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late PublishCheckProcessRunner processRunner; late CommandRunner runner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = PublishCheckProcessRunner(); - final PublishCheckCommand publishCheckCommand = - PublishCheckCommand(packagesDir, processRunner: processRunner); + final PublishCheckCommand publishCheckCommand = PublishCheckCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner( 'publish_check_command', @@ -339,12 +344,16 @@ void main() { }); expect(hasError, isTrue); - expect(output.first, r''' + expect(output.first, contains(r''' { "status": "error", "humanMessage": [ "\n============================================================\n|| Running for no_publish_a\n============================================================\n", - "Failed to parse `pubspec.yaml` at /packages/no_publish_a/pubspec.yaml: ParsedYamlException: line 1, column 1: Not a map\n ╷\n1 │ bad-yaml\n │ ^^^^^^^^\n ╵}", + "Failed to parse `pubspec.yaml` at /packages/no_publish_a/pubspec.yaml: ParsedYamlException:''')); + // This is split into two checks since the details of the YamlException + // aren't controlled by this package, so asserting its exact format would + // make the test fragile to irrelevant changes in those details. + expect(output.first, contains(r''' "no pubspec", "\n============================================================\n|| Running for no_publish_b\n============================================================\n", "url https://pub.dev/packages/no_publish_b.json", @@ -356,7 +365,7 @@ void main() { " no_publish_a", "See above for full details." ] -}'''); +}''')); }); }); } diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 497579b02f89..c7df81952641 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -16,6 +16,7 @@ import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -1091,7 +1092,7 @@ class TestProcessRunner extends ProcessRunner { {Directory? workingDirectory}) async { /// Never actually publish anything. Start is always and only used for this /// since it returns something we can route stdin through. - assert(executable == 'flutter' && + assert(executable == getFlutterCommand(const LocalPlatform()) && args.isNotEmpty && args[0] == 'pub' && args[1] == 'publish'); diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 9e633e21b4ab..177ed7f25b4e 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/pubspec_check_command.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; void main() { @@ -16,15 +17,20 @@ void main() { late CommandRunner runner; late RecordingProcessRunner processRunner; late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = fileSystem.currentDirectory.childDirectory('packages'); createPackagesDirectory(parentDir: packagesDir.parent); processRunner = RecordingProcessRunner(); - final PubspecCheckCommand command = - PubspecCheckCommand(packagesDir, processRunner: processRunner); + final PubspecCheckCommand command = PubspecCheckCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner( 'pubspec_check_command', 'Test for pubspec_check_command'); diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index ac0ac4b3dd40..503e24d03056 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -10,6 +10,7 @@ import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/test_command.dart'; +import 'package:platform/platform.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -18,16 +19,21 @@ import 'util.dart'; void main() { group('$TestCommand', () { late FileSystem fileSystem; + late Platform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final TestCommand command = - TestCommand(packagesDir, processRunner: processRunner); + final TestCommand command = TestCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); runner = CommandRunner('test_test', 'Test for $TestCommand'); runner.addCommand(command); @@ -44,10 +50,10 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'flutter', const ['test', '--color'], plugin1Dir.path), - ProcessCall( - 'flutter', const ['test', '--color'], plugin2Dir.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['test', '--color'], plugin1Dir.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['test', '--color'], plugin2Dir.path), ]), ); }); @@ -58,7 +64,9 @@ void main() { createFakePlugin('plugin2', packagesDir, extraFiles: ['test/empty_test.dart']); - processRunner.mockProcessesForExecutable['flutter'] = [ + processRunner + .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [ MockProcess.failing(), // plugin 1 test MockProcess.succeeding(), // plugin 2 test ]; @@ -88,8 +96,8 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'flutter', const ['test', '--color'], plugin2Dir.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['test', '--color'], plugin2Dir.path), ]), ); }); @@ -107,7 +115,7 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - 'flutter', + getFlutterCommand(mockPlatform), const ['test', '--color', '--enable-experiment=exp1'], pluginDir.path), ProcessCall('dart', const ['pub', 'get'], packageDir.path), @@ -183,7 +191,7 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - 'flutter', + getFlutterCommand(mockPlatform), const ['test', '--color', '--platform=chrome'], pluginDir.path), ]), @@ -203,7 +211,7 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - 'flutter', + getFlutterCommand(mockPlatform), const ['test', '--color', '--enable-experiment=exp1'], pluginDir.path), ProcessCall('dart', const ['pub', 'get'], packageDir.path), diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index b65b1fcaa84a..1984a25cc430 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -14,8 +14,14 @@ import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'package:quiver/collection.dart'; +/// Returns the exe name that command will use when running Flutter on +/// [platform]. +String getFlutterCommand(Platform platform) => + platform.isWindows ? 'flutter.bat' : 'flutter'; + /// Creates a packages directory in the given location. /// /// If [parentDir] is set the packages directory will be created there, diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 6fbed9c691b3..587de1a58cd9 100644 --- a/script/tool/test/version_check_command_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -18,6 +18,7 @@ import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; import 'common/plugin_command_test.mocks.dart'; +import 'mocks.dart'; import 'util.dart'; void testAllowedVersion( @@ -46,6 +47,7 @@ void main() { const String indentation = ' '; group('$VersionCheckCommand', () { FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; @@ -55,6 +57,7 @@ void main() { setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); gitDirCommands = >[]; gitShowResponses = {}; @@ -80,7 +83,7 @@ void main() { }); processRunner = RecordingProcessRunner(); final VersionCheckCommand command = VersionCheckCommand(packagesDir, - processRunner: processRunner, gitDir: gitDir); + processRunner: processRunner, platform: mockPlatform, gitDir: gitDir); runner = CommandRunner( 'version_check_command', 'Test for $VersionCheckCommand'); diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index 10329b18980c..aa6d23fb56f5 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -90,16 +90,18 @@ void main() { group('test xctest_command', () { late FileSystem fileSystem; + late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; late RecordingProcessRunner processRunner; setUp(() { fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(isMacOS: true); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final XCTestCommand command = - XCTestCommand(packagesDir, processRunner: processRunner); + final XCTestCommand command = XCTestCommand(packagesDir, + processRunner: processRunner, platform: mockPlatform); runner = CommandRunner('xctest_command', 'Test for xctest_command'); runner.addCommand(command); From d68f263131ece9380c6a1abde47da73e7a5dc21f Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 12 Jul 2021 17:57:39 -0700 Subject: [PATCH 128/364] [flutter_plugin_tools] Support format on Windows (#4150) Allows `format` to run successfully on Windows: - Ensures that no calls exceed the command length limit. - Allows specifying a `java` path to make it easier to run without a system Java (e.g., by pointing to the `java` binary in an Android Studio installation). - Adds clear error messages when `java` or `clang-format` is missing since it's very non-obvious what's wrong otherwise. Bumps the version, which I intended to do in the previous PR but apparently didn't push to the PR. --- script/tool/CHANGELOG.md | 3 +- script/tool/lib/src/format_command.dart | 147 ++++++++++-- script/tool/pubspec.yaml | 2 +- script/tool/test/format_command_test.dart | 275 ++++++++++++++++++++-- 4 files changed, 383 insertions(+), 44 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 3f31a4953f6b..9db94dda37da 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,4 +1,4 @@ -## NEXT +## 0.4.0 - Modified the output format of many commands - **Breaking change**: `firebase-test-lab` no longer supports `*_e2e.dart` @@ -10,6 +10,7 @@ - Deprecated `--plugins` in favor of new `--packages`. `--plugins` continues to work for now, but will be removed in the future. - Make `drive-examples` device detection robust against Flutter tool banners. +- `format` is now supported on Windows. ## 0.3.0 diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index 7954fd044ce4..c67fb96d2835 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -7,17 +7,31 @@ import 'dart:io' as io; import 'package:file/file.dart'; import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; import 'package:platform/platform.dart'; -import 'package:quiver/iterables.dart'; import 'common/core.dart'; import 'common/plugin_command.dart'; import 'common/process_runner.dart'; +/// In theory this should be 8191, but in practice that was still resulting in +/// "The input line is too long" errors. This was chosen as a value that worked +/// in practice in testing with flutter/plugins, but may need to be adjusted +/// based on further experience. +@visibleForTesting +const int windowsCommandLineMax = 8000; + +/// This value is picked somewhat arbitrarily based on checking `ARG_MAX` on a +/// macOS and Linux machine. If anyone encounters a lower limit in pratice, it +/// can be lowered accordingly. +@visibleForTesting +const int nonWindowsCommandLineMax = 1000000; + const int _exitClangFormatFailed = 3; const int _exitFlutterFormatFailed = 4; const int _exitJavaFormatFailed = 5; const int _exitGitFailed = 6; +const int _exitDependencyMissing = 7; final Uri _googleFormatterUrl = Uri.https('github.com', '/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar'); @@ -32,8 +46,9 @@ class FormatCommand extends PluginCommand { }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addFlag('fail-on-change', hide: true); argParser.addOption('clang-format', - defaultsTo: 'clang-format', - help: 'Path to executable of clang-format.'); + defaultsTo: 'clang-format', help: 'Path to "clang-format" executable.'); + argParser.addOption('java', + defaultsTo: 'java', help: 'Path to "java" executable.'); } @override @@ -52,7 +67,8 @@ class FormatCommand extends PluginCommand { // This class is not based on PackageLoopingCommand because running the // formatters separately for each package is an order of magnitude slower, // due to the startup overhead of the formatters. - final Iterable files = await _getFilteredFilePaths(getFiles()); + final Iterable files = + await _getFilteredFilePaths(getFiles(), relativeTo: packagesDir); await _formatDart(files); await _formatJava(files, googleFormatterPath); await _formatCppAndObjectiveC(files); @@ -112,19 +128,18 @@ class FormatCommand extends PluginCommand { final Iterable clangFiles = _getPathsWithExtensions( files, {'.h', '.m', '.mm', '.cc', '.cpp'}); if (clangFiles.isNotEmpty) { - print('Formatting .cc, .cpp, .h, .m, and .mm files...'); - final Iterable> batches = partition(clangFiles, 100); - int exitCode = 0; - for (final List batch in batches) { - batch.sort(); // For ease of testing; partition changes the order. - exitCode = await processRunner.runAndStream( - getStringArg('clang-format'), - ['-i', '--style=Google', ...batch], - workingDir: packagesDir); - if (exitCode != 0) { - break; - } + final String clangFormat = getStringArg('clang-format'); + if (!await _hasDependency(clangFormat)) { + printError( + 'Unable to run \'clang-format\'. Make sure that it is in your ' + 'path, or provide a full path with --clang-format.'); + throw ToolExit(_exitDependencyMissing); } + + print('Formatting .cc, .cpp, .h, .m, and .mm files...'); + final int exitCode = await _runBatched( + getStringArg('clang-format'), ['-i', '--style=Google'], + files: clangFiles); if (exitCode != 0) { printError( 'Failed to format C, C++, and Objective-C files: exit code $exitCode.'); @@ -138,10 +153,18 @@ class FormatCommand extends PluginCommand { final Iterable javaFiles = _getPathsWithExtensions(files, {'.java'}); if (javaFiles.isNotEmpty) { + final String java = getStringArg('java'); + if (!await _hasDependency(java)) { + printError( + 'Unable to run \'java\'. Make sure that it is in your path, or ' + 'provide a full path with --java.'); + throw ToolExit(_exitDependencyMissing); + } + print('Formatting .java files...'); - final int exitCode = await processRunner.runAndStream('java', - ['-jar', googleFormatterPath, '--replace', ...javaFiles], - workingDir: packagesDir); + final int exitCode = await _runBatched( + java, ['-jar', googleFormatterPath, '--replace'], + files: javaFiles); if (exitCode != 0) { printError('Failed to format Java files: exit code $exitCode.'); throw ToolExit(_exitJavaFormatFailed); @@ -156,9 +179,8 @@ class FormatCommand extends PluginCommand { print('Formatting .dart files...'); // `flutter format` doesn't require the project to actually be a Flutter // project. - final int exitCode = await processRunner.runAndStream( - flutterCommand, ['format', ...dartFiles], - workingDir: packagesDir); + final int exitCode = await _runBatched(flutterCommand, ['format'], + files: dartFiles); if (exitCode != 0) { printError('Failed to format Dart files: exit code $exitCode.'); throw ToolExit(_exitFlutterFormatFailed); @@ -166,7 +188,12 @@ class FormatCommand extends PluginCommand { } } - Future> _getFilteredFilePaths(Stream files) async { + /// Given a stream of [files], returns the paths of any that are not in known + /// locations to ignore, relative to [relativeTo]. + Future> _getFilteredFilePaths( + Stream files, { + required Directory relativeTo, + }) async { // Returns a pattern to check for [directories] as a subset of a file path. RegExp pathFragmentForDirectories(List directories) { String s = path.separator; @@ -177,8 +204,10 @@ class FormatCommand extends PluginCommand { return RegExp('(?:^|$s)${path.joinAll(directories)}$s'); } + final String fromPath = relativeTo.path; + return files - .map((File file) => file.path) + .map((File file) => path.relative(file.path, from: fromPath)) .where((String path) => // Ignore files in build/ directories (e.g., headers of frameworks) // to avoid useless extra work in local repositories. @@ -212,4 +241,74 @@ class FormatCommand extends PluginCommand { return javaFormatterPath; } + + /// Returns true if [command] can be run successfully. + Future _hasDependency(String command) async { + try { + final io.ProcessResult result = + await processRunner.run(command, ['--version']); + if (result.exitCode != 0) { + return false; + } + } on io.ProcessException { + // Thrown when the binary is missing entirely. + return false; + } + return true; + } + + /// Runs [command] on [arguments] on all of the files in [files], batched as + /// necessary to avoid OS command-line length limits. + /// + /// Returns the exit code of the first failure, which stops the run, or 0 + /// on success. + Future _runBatched( + String command, + List arguments, { + required Iterable files, + }) async { + final int commandLineMax = + platform.isWindows ? windowsCommandLineMax : nonWindowsCommandLineMax; + + // Compute the max length of the file argument portion of a batch. + // Add one to each argument's length for the space before it. + final int argumentTotalLength = + arguments.fold(0, (int sum, String arg) => sum + arg.length + 1); + final int batchMaxTotalLength = + commandLineMax - command.length - argumentTotalLength; + + // Run the command in batches. + final List> batches = + _partitionFileList(files, maxStringLength: batchMaxTotalLength); + for (final List batch in batches) { + batch.sort(); // For ease of testing. + final int exitCode = await processRunner.runAndStream( + command, [...arguments, ...batch], + workingDir: packagesDir); + if (exitCode != 0) { + return exitCode; + } + } + return 0; + } + + /// Partitions [files] into batches whose max string length as parameters to + /// a command (including the spaces between them, and between the list and + /// the command itself) is no longer than [maxStringLength]. + List> _partitionFileList(Iterable files, + {required int maxStringLength}) { + final List> batches = >[[]]; + int currentBatchTotalLength = 0; + for (final String file in files) { + final int length = file.length + 1 /* for the space */; + if (currentBatchTotalLength + length > maxStringLength) { + // Start a new batch. + batches.add([]); + currentBatchTotalLength = 0; + } + batches.last.add(file); + currentBatchTotalLength += length; + } + return batches; + } } diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 6273fe9bf277..7dadc598d4b4 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.3.0 +version: 0.4.0 dependencies: args: ^2.1.0 diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index fabef31a1b64..4728c3136556 100644 --- a/script/tool/test/format_command_test.dart +++ b/script/tool/test/format_command_test.dart @@ -19,8 +19,8 @@ void main() { late FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; - late p.Context path; late RecordingProcessRunner processRunner; + late FormatCommand analyzeCommand; late CommandRunner runner; late String javaFormatPath; @@ -29,7 +29,7 @@ void main() { mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final FormatCommand analyzeCommand = FormatCommand( + analyzeCommand = FormatCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, @@ -37,7 +37,7 @@ void main() { // Create the java formatter file that the command checks for, to avoid // a download. - path = analyzeCommand.path; + final p.Context path = analyzeCommand.path; javaFormatPath = path.join(path.dirname(path.fromUri(mockPlatform.script)), 'google-java-format-1.3-all-deps.jar'); fileSystem.file(javaFormatPath).createSync(recursive: true); @@ -46,13 +46,39 @@ void main() { runner.addCommand(analyzeCommand); }); - List _getAbsolutePaths( + /// Returns a modified version of a list of [relativePaths] that are relative + /// to [package] to instead be relative to [packagesDir]. + List _getPackagesDirRelativePaths( Directory package, List relativePaths) { + final p.Context path = analyzeCommand.path; + final String relativeBase = + path.relative(package.path, from: packagesDir.path); return relativePaths - .map((String relativePath) => path.join(package.path, relativePath)) + .map((String relativePath) => path.join(relativeBase, relativePath)) .toList(); } + /// Returns a list of [count] relative paths to pass to [createFakePlugin] + /// with name [pluginName] such that each path will be 99 characters long + /// relative to [packagesDir]. + /// + /// This is for each of testing batching, since it means each file will + /// consume 100 characters of the batch length. + List _get99CharacterPathExtraFiles(String pluginName, int count) { + final int padding = 99 - + pluginName.length - + 1 - // the path separator after the plugin name + 1 - // the path separator after the padding + 10; // the file name + const int filenameBase = 10000; + + final p.Context path = analyzeCommand.path; + return [ + for (int i = filenameBase; i < filenameBase + count; ++i) + path.join('a' * padding, '$i.dart'), + ]; + } + test('formats .dart files', () async { const List files = [ 'lib/a.dart', @@ -71,8 +97,11 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - 'flutter', - ['format', ..._getAbsolutePaths(pluginDir, files)], + getFlutterCommand(mockPlatform), + [ + 'format', + ..._getPackagesDirRelativePaths(pluginDir, files) + ], packagesDir.path), ])); }); @@ -85,9 +114,8 @@ void main() { ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() - ]; + processRunner.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [MockProcess.failing()]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { @@ -118,19 +146,20 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + const ProcessCall('java', ['--version'], null), ProcessCall( 'java', [ '-jar', javaFormatPath, '--replace', - ..._getAbsolutePaths(pluginDir, files) + ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); - test('fails if Java formatter fails', () async { + test('fails with a clear message if Java is not in the path', () async { const List files = [ 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', @@ -146,6 +175,33 @@ void main() { commandError = e; }); + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Unable to run \'java\'. Make sure that it is in your path, or ' + 'provide a full path with --java.'), + ])); + }); + + test('fails if Java formatter fails', () async { + const List files = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', + 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['java'] = [ + MockProcess.succeeding(), // check for working java + MockProcess.failing(), // format + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); expect( output, @@ -154,6 +210,35 @@ void main() { ])); }); + test('honors --java flag', () async { + const List files = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', + 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format', '--java=/path/to/java']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall('/path/to/java', ['--version'], null), + ProcessCall( + '/path/to/java', + [ + '-jar', + javaFormatPath, + '--replace', + ..._getPackagesDirRelativePaths(pluginDir, files) + ], + packagesDir.path), + ])); + }); + test('formats c-ish files', () async { const List files = [ 'ios/Classes/Foo.h', @@ -174,18 +259,20 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + const ProcessCall('clang-format', ['--version'], null), ProcessCall( 'clang-format', [ '-i', '--style=Google', - ..._getAbsolutePaths(pluginDir, files) + ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); - test('fails if clang-format fails', () async { + test('fails with a clear message if clang-format is not in the path', + () async { const List files = [ 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', @@ -201,6 +288,62 @@ void main() { commandError = e; }); + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Unable to run \'clang-format\'. Make sure that it is in your ' + 'path, or provide a full path with --clang-format.'), + ])); + }); + + test('honors --clang-format flag', () async { + const List files = [ + 'windows/foo_plugin.cpp', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint( + runner, ['format', '--clang-format=/path/to/clang-format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall( + '/path/to/clang-format', ['--version'], null), + ProcessCall( + '/path/to/clang-format', + [ + '-i', + '--style=Google', + ..._getPackagesDirRelativePaths(pluginDir, files) + ], + packagesDir.path), + ])); + }); + + test('fails if clang-format fails', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['clang-format'] = [ + MockProcess.succeeding(), // check for working clang-format + MockProcess.failing(), // format + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); expect( output, @@ -246,12 +389,15 @@ void main() { [ '-i', '--style=Google', - ..._getAbsolutePaths(pluginDir, clangFiles) + ..._getPackagesDirRelativePaths(pluginDir, clangFiles) ], packagesDir.path), ProcessCall( - 'flutter', - ['format', ..._getAbsolutePaths(pluginDir, dartFiles)], + getFlutterCommand(mockPlatform), + [ + 'format', + ..._getPackagesDirRelativePaths(pluginDir, dartFiles) + ], packagesDir.path), ProcessCall( 'java', @@ -259,7 +405,7 @@ void main() { '-jar', javaFormatPath, '--replace', - ..._getAbsolutePaths(pluginDir, javaFiles) + ..._getPackagesDirRelativePaths(pluginDir, javaFiles) ], packagesDir.path), ])); @@ -348,4 +494,97 @@ void main() { contains('Unable to determine diff.'), ])); }); + + test('Batches moderately long file lists on Windows', () async { + mockPlatform.isWindows = true; + + const String pluginName = 'a_plugin'; + // -1 since the command itself takes some length. + const int batchSize = (windowsCommandLineMax ~/ 100) - 1; + + // Make the file list one file longer than would fit in the batch. + final List batch1 = + _get99CharacterPathExtraFiles(pluginName, batchSize + 1); + final String extraFile = batch1.removeLast(); + + createFakePlugin( + pluginName, + packagesDir, + extraFiles: [...batch1, extraFile], + ); + + await runCapturingPrint(runner, ['format']); + + // Ensure that it was batched... + expect(processRunner.recordedCalls.length, 2); + // ... and that the spillover into the second batch was only one file. + expect( + processRunner.recordedCalls, + contains( + ProcessCall( + getFlutterCommand(mockPlatform), + [ + 'format', + '$pluginName\\$extraFile', + ], + packagesDir.path), + )); + }); + + // Validates that the Windows limit--which is much lower than the limit on + // other platforms--isn't being used on all platforms, as that would make + // formatting slower on Linux and macOS. + test('Does not batch moderately long file lists on non-Windows', () async { + const String pluginName = 'a_plugin'; + // -1 since the command itself takes some length. + const int batchSize = (windowsCommandLineMax ~/ 100) - 1; + + // Make the file list one file longer than would fit in a Windows batch. + final List batch = + _get99CharacterPathExtraFiles(pluginName, batchSize + 1); + + createFakePlugin( + pluginName, + packagesDir, + extraFiles: batch, + ); + + await runCapturingPrint(runner, ['format']); + + expect(processRunner.recordedCalls.length, 1); + }); + + test('Batches extremely long file lists on non-Windows', () async { + const String pluginName = 'a_plugin'; + // -1 since the command itself takes some length. + const int batchSize = (nonWindowsCommandLineMax ~/ 100) - 1; + + // Make the file list one file longer than would fit in the batch. + final List batch1 = + _get99CharacterPathExtraFiles(pluginName, batchSize + 1); + final String extraFile = batch1.removeLast(); + + createFakePlugin( + pluginName, + packagesDir, + extraFiles: [...batch1, extraFile], + ); + + await runCapturingPrint(runner, ['format']); + + // Ensure that it was batched... + expect(processRunner.recordedCalls.length, 2); + // ... and that the spillover into the second batch was only one file. + expect( + processRunner.recordedCalls, + contains( + ProcessCall( + getFlutterCommand(mockPlatform), + [ + 'format', + '$pluginName/$extraFile', + ], + packagesDir.path), + )); + }); } From ce371a6e7caa7302ecff7527d5afec6c4ccb9d46 Mon Sep 17 00:00:00 2001 From: Aneesh Rao Date: Tue, 13 Jul 2021 22:11:03 +0530 Subject: [PATCH 129/364] [webview_flutter] Fix broken keyboard issue link (#3266) --- packages/webview_flutter/CHANGELOG.md | 4 ++++ packages/webview_flutter/README.md | 2 +- packages/webview_flutter/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index f43812d438f8..46f5e045ddd8 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.10 + +* Fix keyboard issues link in the README. + ## 2.0.9 * Add iOS UI integration test target. diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/README.md index 2bfc312d36ab..9a613f5f7a8e 100644 --- a/packages/webview_flutter/README.md +++ b/packages/webview_flutter/README.md @@ -19,7 +19,7 @@ The WebView is relying on [Platform Views](https://flutter.dev/docs/development/platform-integration/platform-views) to embed the Android’s webview within the Flutter app. By default a Virtual Display based platform view backend is used, this implementation has multiple -[keyboard](https://github.com/flutter/flutter/issues?q=is%3Aopen+label%3Avd-only+label%3A%22p%3A+webview-keyboard%22). +[keyboard issues](https://github.com/flutter/flutter/issues?q=is%3Aopen+label%3Avd-only+label%3A%22p%3A+webview-keyboard%22). When keyboard input is required we recommend using the Hybrid Composition based platform views implementation. Note that on Android versions prior to Android 10 Hybrid Composition has some [performance drawbacks](https://flutter.dev/docs/development/platform-integration/platform-views#performance). diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index 6acee01924a6..4d984beeed96 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.9 +version: 2.0.10 environment: sdk: ">=2.12.0 <3.0.0" From fbb4b3a85e3a554ef24b5d19898cdc30537737fa Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 13 Jul 2021 13:25:41 -0700 Subject: [PATCH 130/364] [flutter_plugin_tools] Improve license-check output (#4154) Currently each type of check handles its output in isolation, which creates confusing output when the last check succeeds but an earlier check fails, since the end of the output will just be a success message. This makes the output follow the same basic approach as the package looper commands, where all failures are collected, and then a final summary is presented at the end, so the last message will always reflect the important details. It also adopts the colorized output now used by most other commands. --- script/tool/CHANGELOG.md | 4 + .../tool/lib/src/license_check_command.dart | 122 ++++++++------- .../tool/test/license_check_command_test.dart | 147 +++++++++++++----- 3 files changed, 175 insertions(+), 98 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 9db94dda37da..17b28927538d 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +- Improved `license-check` output. + ## 0.4.0 - Modified the output format of many commands diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index 093f8143df4f..e68585c44bdf 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -107,21 +107,65 @@ class LicenseCheckCommand extends PluginCommand { @override Future run() async { - final Iterable codeFiles = (await _getAllFiles()).where((File file) => + final Iterable allFiles = await _getAllFiles(); + + final Iterable codeFiles = allFiles.where((File file) => _codeFileExtensions.contains(p.extension(file.path)) && !_shouldIgnoreFile(file)); - final Iterable firstPartyLicenseFiles = (await _getAllFiles()).where( - (File file) => - path.basename(file.basename) == 'LICENSE' && !_isThirdParty(file)); + final Iterable firstPartyLicenseFiles = allFiles.where((File file) => + path.basename(file.basename) == 'LICENSE' && !_isThirdParty(file)); - final bool copyrightCheckSucceeded = await _checkCodeLicenses(codeFiles); - print('\n=======================================\n'); - final bool licenseCheckSucceeded = + final List licenseFileFailures = await _checkLicenseFiles(firstPartyLicenseFiles); + final Map<_LicenseFailureType, List> codeFileFailures = + await _checkCodeLicenses(codeFiles); + + bool passed = true; + + print('\n=======================================\n'); + + if (licenseFileFailures.isNotEmpty) { + passed = false; + printError( + 'The following LICENSE files do not follow the expected format:'); + for (final File file in licenseFileFailures) { + printError(' ${file.path}'); + } + printError('Please ensure that they use the exact format used in this ' + 'repository".\n'); + } + + if (codeFileFailures[_LicenseFailureType.incorrectFirstParty]!.isNotEmpty) { + passed = false; + printError('The license block for these files is missing or incorrect:'); + for (final File file + in codeFileFailures[_LicenseFailureType.incorrectFirstParty]!) { + printError(' ${file.path}'); + } + printError( + 'If this third-party code, move it to a "third_party/" directory, ' + 'otherwise ensure that you are using the exact copyright and license ' + 'text used by all first-party files in this repository.\n'); + } + + if (codeFileFailures[_LicenseFailureType.unknownThirdParty]!.isNotEmpty) { + passed = false; + printError( + 'No recognized license was found for the following third-party files:'); + for (final File file + in codeFileFailures[_LicenseFailureType.unknownThirdParty]!) { + printError(' ${file.path}'); + } + print('Please check that they have a license at the top of the file. ' + 'If they do, the license check needs to be updated to recognize ' + 'the new third-party license block.\n'); + } - if (!copyrightCheckSucceeded || !licenseCheckSucceeded) { + if (!passed) { throw ToolExit(1); } + + printSuccess('All files passed validation!'); } // Creates the expected copyright+license block for first-party code. @@ -135,9 +179,10 @@ class LicenseCheckCommand extends PluginCommand { '${comment}found in the LICENSE file.$suffix\n'; } - // Checks all license blocks for [codeFiles], returning false if any of them - // fail validation. - Future _checkCodeLicenses(Iterable codeFiles) async { + /// Checks all license blocks for [codeFiles], returning any that fail + /// validation. + Future>> _checkCodeLicenses( + Iterable codeFiles) async { final List incorrectFirstPartyFiles = []; final List unrecognizedThirdPartyFiles = []; @@ -171,7 +216,6 @@ class LicenseCheckCommand extends PluginCommand { } } } - print('\n'); // Sort by path for more usable output. final int Function(File, File) pathCompare = @@ -179,38 +223,14 @@ class LicenseCheckCommand extends PluginCommand { incorrectFirstPartyFiles.sort(pathCompare); unrecognizedThirdPartyFiles.sort(pathCompare); - if (incorrectFirstPartyFiles.isNotEmpty) { - print('The license block for these files is missing or incorrect:'); - for (final File file in incorrectFirstPartyFiles) { - print(' ${file.path}'); - } - print('If this third-party code, move it to a "third_party/" directory, ' - 'otherwise ensure that you are using the exact copyright and license ' - 'text used by all first-party files in this repository.\n'); - } - - if (unrecognizedThirdPartyFiles.isNotEmpty) { - print( - 'No recognized license was found for the following third-party files:'); - for (final File file in unrecognizedThirdPartyFiles) { - print(' ${file.path}'); - } - print('Please check that they have a license at the top of the file. ' - 'If they do, the license check needs to be updated to recognize ' - 'the new third-party license block.\n'); - } - - final bool succeeded = - incorrectFirstPartyFiles.isEmpty && unrecognizedThirdPartyFiles.isEmpty; - if (succeeded) { - print('All source files passed validation!'); - } - return succeeded; + return <_LicenseFailureType, List>{ + _LicenseFailureType.incorrectFirstParty: incorrectFirstPartyFiles, + _LicenseFailureType.unknownThirdParty: unrecognizedThirdPartyFiles, + }; } - // Checks all provide LICENSE files, returning false if any of them - // fail validation. - Future _checkLicenseFiles(Iterable files) async { + /// Checks all provided LICENSE [files], returning any that fail validation. + Future> _checkLicenseFiles(Iterable files) async { final List incorrectLicenseFiles = []; for (final File file in files) { @@ -219,22 +239,8 @@ class LicenseCheckCommand extends PluginCommand { incorrectLicenseFiles.add(file); } } - print('\n'); - if (incorrectLicenseFiles.isNotEmpty) { - print('The following LICENSE files do not follow the expected format:'); - for (final File file in incorrectLicenseFiles) { - print(' ${file.path}'); - } - print( - 'Please ensure that they use the exact format used in this repository".\n'); - } - - final bool succeeded = incorrectLicenseFiles.isEmpty; - if (succeeded) { - print('All LICENSE files passed validation!'); - } - return succeeded; + return incorrectLicenseFiles; } bool _shouldIgnoreFile(File file) { @@ -255,3 +261,5 @@ class LicenseCheckCommand extends PluginCommand { .map((FileSystemEntity file) => file as File) .toList(); } + +enum _LicenseFailureType { incorrectFirstParty, unknownThirdParty } diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart index 64adc9214d80..288cf4696a59 100644 --- a/script/tool/test/license_check_command_test.dart +++ b/script/tool/test/license_check_command_test.dart @@ -131,8 +131,12 @@ void main() { await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check a file. - expect(output, contains('Checking checked.cc')); - expect(output, contains('All source files passed validation!')); + expect( + output, + containsAllInOrder([ + contains('Checking checked.cc'), + contains('All files passed validation!'), + ])); }); test('handles the comment styles for all supported languages', () async { @@ -150,10 +154,14 @@ void main() { await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the files. - expect(output, contains('Checking file_a.cc')); - expect(output, contains('Checking file_b.sh')); - expect(output, contains('Checking file_c.html')); - expect(output, contains('All source files passed validation!')); + expect( + output, + containsAllInOrder([ + contains('Checking file_a.cc'), + contains('Checking file_b.sh'), + contains('Checking file_c.html'), + contains('All files passed validation!'), + ])); }); test('fails if any checked files are missing license blocks', () async { @@ -176,12 +184,14 @@ void main() { // Failure should give information about the problematic files. expect( output, - contains( - 'The license block for these files is missing or incorrect:')); - expect(output, contains(' bad.cc')); - expect(output, contains(' bad.h')); + containsAllInOrder([ + contains( + 'The license block for these files is missing or incorrect:'), + contains(' bad.cc'), + contains(' bad.h'), + ])); // Failure shouldn't print the success message. - expect(output, isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('fails if any checked files are missing just the copyright', () async { @@ -202,11 +212,13 @@ void main() { // Failure should give information about the problematic files. expect( output, - contains( - 'The license block for these files is missing or incorrect:')); - expect(output, contains(' bad.cc')); + containsAllInOrder([ + contains( + 'The license block for these files is missing or incorrect:'), + contains(' bad.cc'), + ])); // Failure shouldn't print the success message. - expect(output, isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('fails if any checked files are missing just the license', () async { @@ -227,11 +239,13 @@ void main() { // Failure should give information about the problematic files. expect( output, - contains( - 'The license block for these files is missing or incorrect:')); - expect(output, contains(' bad.cc')); + containsAllInOrder([ + contains( + 'The license block for these files is missing or incorrect:'), + contains(' bad.cc'), + ])); // Failure shouldn't print the success message. - expect(output, isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('fails if any third-party code is not in a third_party directory', @@ -250,11 +264,13 @@ void main() { // Failure should give information about the problematic files. expect( output, - contains( - 'The license block for these files is missing or incorrect:')); - expect(output, contains(' third_party.cc')); + containsAllInOrder([ + contains( + 'The license block for these files is missing or incorrect:'), + contains(' third_party.cc'), + ])); // Failure shouldn't print the success message. - expect(output, isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('succeeds for third-party code in a third_party directory', () async { @@ -276,8 +292,12 @@ void main() { await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the file. - expect(output, contains('Checking a_plugin/lib/src/third_party/file.cc')); - expect(output, contains('All source files passed validation!')); + expect( + output, + containsAllInOrder([ + contains('Checking a_plugin/lib/src/third_party/file.cc'), + contains('All files passed validation!'), + ])); }); test('allows first-party code in a third_party directory', () async { @@ -294,9 +314,12 @@ void main() { await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the file. - expect(output, - contains('Checking a_plugin/lib/src/third_party/first_party.cc')); - expect(output, contains('All source files passed validation!')); + expect( + output, + containsAllInOrder([ + contains('Checking a_plugin/lib/src/third_party/first_party.cc'), + contains('All files passed validation!'), + ])); }); test('fails for licenses that the tool does not expect', () async { @@ -320,11 +343,13 @@ void main() { // Failure should give information about the problematic files. expect( output, - contains( - 'No recognized license was found for the following third-party files:')); - expect(output, contains(' third_party/bad.cc')); + containsAllInOrder([ + contains( + 'No recognized license was found for the following third-party files:'), + contains(' third_party/bad.cc'), + ])); // Failure shouldn't print the success message. - expect(output, isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('Apache is not recognized for new authors without validation changes', @@ -353,11 +378,13 @@ void main() { // Failure should give information about the problematic files. expect( output, - contains( - 'No recognized license was found for the following third-party files:')); - expect(output, contains(' third_party/bad.cc')); + containsAllInOrder([ + contains( + 'No recognized license was found for the following third-party files:'), + contains(' third_party/bad.cc'), + ])); // Failure shouldn't print the success message. - expect(output, isNot(contains('All source files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('passes if all first-party LICENSE files are correctly formatted', @@ -370,8 +397,12 @@ void main() { await runCapturingPrint(runner, ['license-check']); // Sanity check that the test did actually check the file. - expect(output, contains('Checking LICENSE')); - expect(output, contains('All LICENSE files passed validation!')); + expect( + output, + containsAllInOrder([ + contains('Checking LICENSE'), + contains('All files passed validation!'), + ])); }); test('fails if any first-party LICENSE files are incorrectly formatted', @@ -387,7 +418,7 @@ void main() { }); expect(commandError, isA()); - expect(output, isNot(contains('All LICENSE files passed validation!'))); + expect(output, isNot(contains(contains('All files passed validation!')))); }); test('ignores third-party LICENSE format', () async { @@ -400,8 +431,42 @@ void main() { await runCapturingPrint(runner, ['license-check']); // The file shouldn't be checked. - expect(output, isNot(contains('Checking third_party/LICENSE'))); - expect(output, contains('All LICENSE files passed validation!')); + expect(output, isNot(contains(contains('Checking third_party/LICENSE')))); + }); + + test('outputs all errors at the end', () async { + root.childFile('bad.cc').createSync(); + root + .childDirectory('third_party') + .childFile('bad.cc') + .createSync(recursive: true); + final File license = root.childFile('LICENSE'); + license.createSync(); + license.writeAsStringSync(_incorrectLicenseFileText); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['license-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Checking LICENSE'), + contains('Checking bad.cc'), + contains('Checking third_party/bad.cc'), + contains( + 'The following LICENSE files do not follow the expected format:'), + contains(' LICENSE'), + contains( + 'The license block for these files is missing or incorrect:'), + contains(' bad.cc'), + contains( + 'No recognized license was found for the following third-party files:'), + contains(' third_party/bad.cc'), + ])); }); }); } From cf80430e72e1b7515c50120da137852cda92ee01 Mon Sep 17 00:00:00 2001 From: Yusuf <82844127+ydag@users.noreply.github.com> Date: Wed, 14 Jul 2021 11:26:05 +0200 Subject: [PATCH 131/364] [image_picker] Image picker fix camera device (#3898) --- .../image_picker/image_picker/CHANGELOG.md | 6 +- .../image_picker/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 2 +- .../ios/RunnerTests/ImagePickerPluginTests.m | 111 ++++++++++++++---- .../ios/Classes/FLTImagePickerPlugin.m | 41 ++++--- .../image_picker/image_picker/pubspec.yaml | 2 +- 6 files changed, 123 insertions(+), 42 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 0e49912b4ed4..33178dee8999 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,9 +1,13 @@ +## 0.8.1+4 + +* Fixes an issue where `preferredCameraDevice` option is not working for `getVideo` method. +* Refactor unit tests that were device-only before. + ## 0.8.1+3 * Fix image picker causing a crash when the cache directory is deleted. ## 0.8.1+2 - * Update the example app to support the multi-image feature. ## 0.8.1+1 diff --git a/packages/image_picker/image_picker/example/ios/Podfile b/packages/image_picker/image_picker/example/ios/Podfile index 75efae48b439..8979c25fea5e 100644 --- a/packages/image_picker/image_picker/example/ios/Podfile +++ b/packages/image_picker/image_picker/example/ios/Podfile @@ -31,7 +31,10 @@ target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do + platform :ios, '9.0' inherit! :search_paths + # Pods for testing + pod 'OCMock', '~> 3.8.1' end target 'RunnerUITests' do inherit! :search_paths diff --git a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj index 547c2be4f914..fc1609f5eeda 100644 --- a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj @@ -877,7 +877,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = NHAKRD9N7D; + DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerUITestiOS14/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; diff --git a/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m b/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m index f667526671f7..cc901f084071 100644 --- a/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m +++ b/packages/image_picker/image_picker/example/ios/RunnerTests/ImagePickerPluginTests.m @@ -6,6 +6,7 @@ @import image_picker; @import XCTest; +#import @interface MockViewController : UIViewController @property(nonatomic, retain) UIViewController *mockPresented; @@ -27,15 +28,33 @@ - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker; @end @interface ImagePickerPluginTests : XCTestCase +@property(readonly, nonatomic) id mockUIImagePicker; +@property(readonly, nonatomic) id mockAVCaptureDevice; @end @implementation ImagePickerPluginTests -#pragma mark - Test camera devices, no op on simulators +- (void)setUp { + _mockUIImagePicker = OCMClassMock([UIImagePickerController class]); + _mockAVCaptureDevice = OCMClassMock([AVCaptureDevice class]); +} + - (void)testPluginPickImageDeviceBack { - if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { - return; - } + // UIImagePickerControllerSourceTypeCamera is supported + OCMStub(ClassMethod( + [_mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + .andReturn(YES); + + // UIImagePickerControllerCameraDeviceRear is supported + OCMStub(ClassMethod( + [_mockUIImagePicker isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceRear])) + .andReturn(YES); + + // AVAuthorizationStatusAuthorized is supported + OCMStub([_mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + .andReturn(AVAuthorizationStatusAuthorized); + + // Run test FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"pickImage" @@ -43,14 +62,27 @@ - (void)testPluginPickImageDeviceBack { [plugin handleMethodCall:call result:^(id _Nullable r){ }]; + XCTAssertEqual([plugin getImagePickerController].cameraDevice, UIImagePickerControllerCameraDeviceRear); } - (void)testPluginPickImageDeviceFront { - if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { - return; - } + // UIImagePickerControllerSourceTypeCamera is supported + OCMStub(ClassMethod( + [_mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + .andReturn(YES); + + // UIImagePickerControllerCameraDeviceFront is supported + OCMStub(ClassMethod([_mockUIImagePicker + isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceFront])) + .andReturn(YES); + + // AVAuthorizationStatusAuthorized is supported + OCMStub([_mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + .andReturn(AVAuthorizationStatusAuthorized); + + // Run test FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"pickImage" @@ -58,14 +90,27 @@ - (void)testPluginPickImageDeviceFront { [plugin handleMethodCall:call result:^(id _Nullable r){ }]; + XCTAssertEqual([plugin getImagePickerController].cameraDevice, UIImagePickerControllerCameraDeviceFront); } - (void)testPluginPickVideoDeviceBack { - if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { - return; - } + // UIImagePickerControllerSourceTypeCamera is supported + OCMStub(ClassMethod( + [_mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + .andReturn(YES); + + // UIImagePickerControllerCameraDeviceRear is supported + OCMStub(ClassMethod( + [_mockUIImagePicker isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceRear])) + .andReturn(YES); + + // AVAuthorizationStatusAuthorized is supported + OCMStub([_mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + .andReturn(AVAuthorizationStatusAuthorized); + + // Run test FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"pickVideo" @@ -73,44 +118,62 @@ - (void)testPluginPickVideoDeviceBack { [plugin handleMethodCall:call result:^(id _Nullable r){ }]; + XCTAssertEqual([plugin getImagePickerController].cameraDevice, UIImagePickerControllerCameraDeviceRear); } -- (void)testPluginPickImageDeviceCancelClickMultipleTimes { - if ([UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { - return; - } +- (void)testPluginPickVideoDeviceFront { + // UIImagePickerControllerSourceTypeCamera is supported + OCMStub(ClassMethod( + [_mockUIImagePicker isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera])) + .andReturn(YES); + + // UIImagePickerControllerCameraDeviceFront is supported + OCMStub(ClassMethod([_mockUIImagePicker + isCameraDeviceAvailable:UIImagePickerControllerCameraDeviceFront])) + .andReturn(YES); + + // AVAuthorizationStatusAuthorized is supported + OCMStub([_mockAVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) + .andReturn(AVAuthorizationStatusAuthorized); + + // Run test FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; FlutterMethodCall *call = - [FlutterMethodCall methodCallWithMethodName:@"pickImage" + [FlutterMethodCall methodCallWithMethodName:@"pickVideo" arguments:@{@"source" : @(0), @"cameraDevice" : @(1)}]; [plugin handleMethodCall:call result:^(id _Nullable r){ }]; - plugin.result = ^(id result) { - }; - [plugin imagePickerControllerDidCancel:[plugin getImagePickerController]]; - [plugin imagePickerControllerDidCancel:[plugin getImagePickerController]]; + XCTAssertEqual([plugin getImagePickerController].cameraDevice, + UIImagePickerControllerCameraDeviceFront); } -- (void)testPluginPickVideoDeviceFront { - if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { +#pragma mark - Test camera devices, no op on simulators + +- (void)testPluginPickImageDeviceCancelClickMultipleTimes { + if ([UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { return; } FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; FlutterMethodCall *call = - [FlutterMethodCall methodCallWithMethodName:@"pickVideo" + [FlutterMethodCall methodCallWithMethodName:@"pickImage" arguments:@{@"source" : @(0), @"cameraDevice" : @(1)}]; [plugin handleMethodCall:call result:^(id _Nullable r){ }]; - XCTAssertEqual([plugin getImagePickerController].cameraDevice, - UIImagePickerControllerCameraDeviceFront); + plugin.result = ^(id result) { + + }; + // To ensure the flow does not crash by multiple cancel call + [plugin imagePickerControllerDidCancel:[plugin getImagePickerController]]; + [plugin imagePickerControllerDidCancel:[plugin getImagePickerController]]; } #pragma mark - Test video duration + - (void)testPickingVideoWithDuration { FLTImagePickerPlugin *plugin = [FLTImagePickerPlugin new]; FlutterMethodCall *call = [FlutterMethodCall diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m index 7c91606ba535..4084ae65b5e0 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m @@ -37,7 +37,6 @@ @interface FLTImagePickerPlugin () *)registrar { @@ -70,6 +69,21 @@ - (UIViewController *)viewControllerWithWindow:(UIWindow *)window { return topController; } +/** + * Returns the UIImagePickerControllerCameraDevice to use given [arguments]. + * + * If the cameraDevice value that is fetched from arguments is 1 then returns + * UIImagePickerControllerCameraDeviceFront. If the cameraDevice value that is fetched + * from arguments is 0 then returns UIImagePickerControllerCameraDeviceRear. + * + * @param arguments that should be used to get cameraDevice value. + */ +- (UIImagePickerControllerCameraDevice)getCameraDeviceFromArguments:(NSDictionary *)arguments { + NSInteger cameraDevice = [[arguments objectForKey:@"cameraDevice"] intValue]; + return (cameraDevice == 1) ? UIImagePickerControllerCameraDeviceFront + : UIImagePickerControllerCameraDeviceRear; +} + - (void)pickImageWithPHPicker:(int)maxImagesAllowed API_AVAILABLE(ios(14)) { PHPickerConfiguration *config = [[PHPickerConfiguration alloc] initWithPhotoLibrary:PHPhotoLibrary.sharedPhotoLibrary]; @@ -95,13 +109,9 @@ - (void)pickImageWithUIImagePicker { self.maxImagesAllowed = 1; switch (imageSource) { - case SOURCE_CAMERA: { - NSInteger cameraDevice = [[_arguments objectForKey:@"cameraDevice"] intValue]; - _device = (cameraDevice == 1) ? UIImagePickerControllerCameraDeviceFront - : UIImagePickerControllerCameraDeviceRear; + case SOURCE_CAMERA: [self checkCameraAuthorization]; break; - } case SOURCE_GALLERY: [self checkPhotoAuthorization]; break; @@ -188,11 +198,12 @@ - (void)showCamera { return; } } + UIImagePickerControllerCameraDevice device = [self getCameraDeviceFromArguments:_arguments]; // Camera is not available on simulators if ([UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera] && - [UIImagePickerController isCameraDeviceAvailable:_device]) { + [UIImagePickerController isCameraDeviceAvailable:device]) { _imagePickerController.sourceType = UIImagePickerControllerSourceTypeCamera; - _imagePickerController.cameraDevice = _device; + _imagePickerController.cameraDevice = device; [[self viewControllerWithWindow:nil] presentViewController:_imagePickerController animated:YES completion:nil]; @@ -406,8 +417,8 @@ - (void)picker:(PHPickerViewController *)picker * The difference with initWithCapacity is that initWithCapacity still gives an empty array making * it impossible to add objects on an index larger than the size. * - * @param @size The length of the required array - * @return @NSMutableArray An array of a specified size + * @param size The length of the required array + * @return NSMutableArray An array of a specified size */ - (NSMutableArray *)createNSMutableArrayWithSize:(NSUInteger)size { NSMutableArray *mutableArray = [[NSMutableArray alloc] initWithCapacity:size]; @@ -528,14 +539,14 @@ - (void)saveImageWithPickerInfo:(NSDictionary *)info * Applies NSMutableArray on the FLutterResult. * * NSString must be returned by FlutterResult if the single image - * mode is active. It is checked by @c maxImagesAllowed and - * returns the first object of the @c pathlist. + * mode is active. It is checked by maxImagesAllowed and + * returns the first object of the pathlist. * * NSMutableArray must be returned by FlutterResult if the multi-image - * mode is active. After the @c pathlist count is checked then it returns - * the @c pathlist. + * mode is active. After the pathlist count is checked then it returns + * the pathlist. * - * @param @pathList that should be applied to FlutterResult. + * @param pathList that should be applied to FlutterResult. */ - (void)handleSavedPathList:(NSArray *)pathList { if (!self.result) { diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index bcda757b4bbf..c9866dbcda02 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.1+3 +version: 0.8.1+4 environment: sdk: ">=2.12.0 <3.0.0" From dd2d7397379e13a79b25f8327246e8f9bd0d21e5 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Fri, 16 Jul 2021 03:14:50 +0200 Subject: [PATCH 132/364] [image_picker] Migrate image_picker to package:cross_file (#4073) --- .../image_picker/image_picker/CHANGELOG.md | 9 + packages/image_picker/image_picker/README.md | 84 +--- .../image_picker/example/lib/main.dart | 14 +- .../image_picker/lib/image_picker.dart | 148 +++++- .../image_picker/image_picker/pubspec.yaml | 6 +- .../test/image_picker_deprecated_test.dart | 458 ++++++++++++++++++ .../image_picker/test/image_picker_test.dart | 82 ++-- 7 files changed, 686 insertions(+), 115 deletions(-) create mode 100644 packages/image_picker/image_picker/test/image_picker_deprecated_test.dart diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 33178dee8999..fef3e47cdf1a 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.8.2 + +* Added new methods that return `package:cross_file` `XFile` instances. [Docs](https://pub.dev/documentation/cross_file/latest/index.html). +* Deprecate methods that return `PickedFile` instances: + * `getImage`: use **`pickImage`** instead. + * `getVideo`: use **`pickVideo`** instead. + * `getMultiImage`: use **`pickMultiImage`** instead. + * `getLostData`: use **`retrieveLostData`** instead. + ## 0.8.1+4 * Fixes an issue where `preferredCameraDevice` option is not working for `getVideo` method. diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index 3b3746d9f63e..18fd96d890fd 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -12,7 +12,7 @@ First, add `image_picker` as a [dependency in your pubspec.yaml file](https://fl ### iOS Starting with version **0.8.1** the iOS implementation uses PHPicker to pick (multiple) images on iOS 14 or higher. -As a result of implementing PHPicker it becomes impossible to pick HEIC images on the iOS simulator in iOS 14+. This is a known issue. Please test this on a real device, or test with non-HEIC images until Apple solves this issue.[63426347 - Apple known issue](https://www.google.com/search?q=63426347+apple&sxsrf=ALeKk01YnTMid5S0PYvhL8GbgXJ40ZS[…]t=gws-wiz&ved=0ahUKEwjKh8XH_5HwAhWL_rsIHUmHDN8Q4dUDCA8&uact=5) +As a result of implementing PHPicker it becomes impossible to pick HEIC images on the iOS simulator in iOS 14+. This is a known issue. Please test this on a real device, or test with non-HEIC images until Apple solves this issue.[63426347 - Apple known issue](https://www.google.com/search?q=63426347+apple&sxsrf=ALeKk01YnTMid5S0PYvhL8GbgXJ40ZS[…]t=gws-wiz&ved=0ahUKEwjKh8XH_5HwAhWL_rsIHUmHDN8Q4dUDCA8&uact=5) Add the following keys to your _Info.plist_ file, located in `/ios/Runner/Info.plist`: @@ -37,7 +37,19 @@ If you require your picked image to be stored permanently, it is your responsibi import 'package:image_picker/image_picker.dart'; ... - final PickedFile? pickedFile = await picker.getImage(source: ImageSource.camera); + final ImagePicker _picker = ImagePicker(); + // Pick an image + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + // Capture a photo + final XFile? photo = await _picker.pickImage(source: ImageSource.camera); + // Pick a video + final XFile? image = await _picker.pickVideo(source: ImageSource.gallery); + // Capture a video + final XFile? photo = await _picker.pickVideo(source: ImageSource.camera); + // Pick multiple images + final List? images = await _picker.pickMultiImage(source: ImageSource.gallery); + // Pick multiple photos + final List? photos = await _picker.pickMultiImage(source: ImageSource.camera); ... ``` @@ -46,9 +58,9 @@ import 'package:image_picker/image_picker.dart'; Android system -- although very rarely -- sometimes kills the MainActivity after the image_picker finishes. When this happens, we lost the data selected from the image_picker. You can use `retrieveLostData` to retrieve the lost data in this situation. For example: ```dart -Future retrieveLostData() async { - final LostData response = - await picker.getLostData(); +Future getLostData() async { + final LostDataResponse response = + await picker.retrieveLostData(); if (response.isEmpty) { return; } @@ -68,65 +80,17 @@ Future retrieveLostData() async { There's no way to detect when this happens, so calling this method at the right place is essential. We recommend to wire this into some kind of start up check. Please refer to the example app to see how we used it. -On Android, `getLostData` will only get the last picked image when picking multiple images, see: [#84634](https://github.com/flutter/flutter/issues/84634). +On Android, `retrieveLostData` will only get the last picked image when picking multiple images, see: [#84634](https://github.com/flutter/flutter/issues/84634). -## Deprecation warnings in `pickImage`, `pickVideo` and `LostDataResponse` +## Migrating to 0.8.2+ -Starting with version **0.6.7** of the image_picker plugin, the API of the plugin changed slightly to allow for web implementations to exist. - -The **old methods that returned `dart:io` File objects were marked as deprecated**, and a new set of methods that return [`PickedFile` objects](https://pub.dev/documentation/image_picker_platform_interface/latest/image_picker_platform_interface/PickedFile-class.html) were introduced. - -### How to migrate from to ^0.6.7 - -#### Instantiate the `ImagePicker` - -The new ImagePicker API does not rely in static methods anymore, so the first thing you'll need to do is to create a new instance of the plugin where you need it: - -```dart -final _picker = ImagePicker(); -``` +Starting with version **0.8.2** of the image_picker plugin, new methods have been added for picking files that return `XFile` instances (from the [cross_file](https://pub.dev/packages/cross_file) package) rather than the plugin's own `PickedFile` instances. While the previous methods still exist, it is already recommended to start migrating over to their new equivalents. Eventually, `PickedFile` and the methods that return instances of it will be deprecated and removed. #### Call the new methods -The new methods **receive the same parameters as before**, but they **return a `PickedFile`, instead of a `File`**. The `LostDataResponse` class has been replaced by the [`LostData` class](https://pub.dev/documentation/image_picker_platform_interface/latest/image_picker_platform_interface/LostData-class.html). - | Old API | New API | |---------|---------| -| `File image = await ImagePicker.pickImage(...)` | `PickedFile image = await _picker.getImage(...)` | -| `File video = await ImagePicker.pickVideo(...)` | `PickedFile video = await _picker.getVideo(...)` | -| `LostDataResponse response = await ImagePicker.retrieveLostData()` | `LostData response = await _picker.getLostData()` | - -#### `PickedFile` to `File` - -If your app needs dart:io `File` objects to operate, you may transform `PickedFile` to `File` like so: - -```dart -final pickedFile = await _picker.getImage(...); -final File file = File(pickedFile.path); -``` - -You may also retrieve the bytes from the pickedFile directly if needed: - -```dart -final bytes = await pickedFile.readAsBytes(); -``` - -#### Getting ready for the web platform - -Note that on the web platform (`kIsWeb == true`), `File` is not available, so the `path` of the `PickedFile` will point to a network resource instead: - -```dart -if (kIsWeb) { - image = Image.network(pickedFile.path); -} else { - image = Image.file(File(pickedFile.path)); -} -``` - -Alternatively, the code may be unified at the expense of memory utilization: - -```dart -image = Image.memory(await pickedFile.readAsBytes()) -``` - -Take a look at the changes to the `example` app introduced in version 0.6.7 to see the migration steps applied there. +| `PickedFile image = await _picker.getImage(...)` | `XFile image = await _picker.pickImage(...)` | +| `List images = await _picker.getMultiImage(...)` | `List images = await _picker.pickMultiImage(...)` | +| `PickedFile video = await _picker.getVideo(...)` | `XFile video = await _picker.pickVideo(...)` | +| `LostData response = await _picker.getLostData()` | `LostDataResponse response = await _picker.retrieveLostData()` | \ No newline at end of file diff --git a/packages/image_picker/image_picker/example/lib/main.dart b/packages/image_picker/image_picker/example/lib/main.dart index 71388ef5db2f..2d5fd9aee4a7 100755 --- a/packages/image_picker/image_picker/example/lib/main.dart +++ b/packages/image_picker/image_picker/example/lib/main.dart @@ -36,9 +36,9 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - List? _imageFileList; + List? _imageFileList; - set _imageFile(PickedFile? value) { + set _imageFile(XFile? value) { _imageFileList = value == null ? null : [value]; } @@ -54,7 +54,7 @@ class _MyHomePageState extends State { final TextEditingController maxHeightController = TextEditingController(); final TextEditingController qualityController = TextEditingController(); - Future _playVideo(PickedFile? file) async { + Future _playVideo(XFile? file) async { if (file != null && mounted) { await _disposeVideoController(); late VideoPlayerController controller; @@ -84,14 +84,14 @@ class _MyHomePageState extends State { await _controller!.setVolume(0.0); } if (isVideo) { - final PickedFile? file = await _picker.getVideo( + final XFile? file = await _picker.pickVideo( source: source, maxDuration: const Duration(seconds: 10)); await _playVideo(file); } else if (isMultiImage) { await _displayPickImageDialog(context!, (double? maxWidth, double? maxHeight, int? quality) async { try { - final pickedFileList = await _picker.getMultiImage( + final pickedFileList = await _picker.pickMultiImage( maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: quality, @@ -109,7 +109,7 @@ class _MyHomePageState extends State { await _displayPickImageDialog(context!, (double? maxWidth, double? maxHeight, int? quality) async { try { - final pickedFile = await _picker.getImage( + final pickedFile = await _picker.pickImage( source: source, maxWidth: maxWidth, maxHeight: maxHeight, @@ -214,7 +214,7 @@ class _MyHomePageState extends State { } Future retrieveLostData() async { - final LostData response = await _picker.getLostData(); + final LostDataResponse response = await _picker.retrieveLostData(); if (response.isEmpty) { return; } diff --git a/packages/image_picker/image_picker/lib/image_picker.dart b/packages/image_picker/image_picker/lib/image_picker.dart index 3d08a38d9f6e..5bc99d7f0bb2 100755 --- a/packages/image_picker/image_picker/lib/image_picker.dart +++ b/packages/image_picker/image_picker/lib/image_picker.dart @@ -2,12 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: deprecated_member_use, deprecated_member_use_from_same_package - import 'dart:async'; - import 'package:flutter/foundation.dart'; - import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; export 'package:image_picker_platform_interface/image_picker_platform_interface.dart' @@ -17,7 +13,9 @@ export 'package:image_picker_platform_interface/image_picker_platform_interface. ImageSource, CameraDevice, LostData, + LostDataResponse, PickedFile, + XFile, RetrieveType; /// Provides an easy way to pick an image/video from the image library, @@ -61,6 +59,7 @@ class ImagePicker { /// the camera or photos gallery, no camera is available, plugin is already in use, /// temporary file could not be created (iOS only), plugin activity could not /// be allocated (Android only) or due to an unknown error. + @Deprecated('Switch to using pickImage instead') Future getImage({ required ImageSource source, double? maxWidth, @@ -101,6 +100,7 @@ class ImagePicker { /// be allocated (Android only) or due to an unknown error. /// /// See also [getImage] to allow users to only pick a single image. + @Deprecated('Switch to using pickMultiImage instead') Future?> getMultiImage({ double? maxWidth, double? maxHeight, @@ -135,6 +135,7 @@ class ImagePicker { /// temporary file could not be created and video could not be cached (iOS only), /// plugin activity could not be allocated (Android only) or due to an unknown error. /// + @Deprecated('Switch to using pickVideo instead') Future getVideo({ required ImageSource source, CameraDevice preferredCameraDevice = CameraDevice.rear, @@ -160,7 +161,146 @@ class ImagePicker { /// See also: /// * [LostData], for what's included in the response. /// * [Android Activity Lifecycle](https://developer.android.com/reference/android/app/Activity.html), for more information on MainActivity destruction. + @Deprecated('Switch to using retrieveLostData instead') Future getLostData() { return platform.retrieveLostData(); } + + /// Returns an [XFile] object wrapping the image that was picked. + /// + /// The returned [XFile] is intended to be used within a single APP session. Do not save the file path and use it across sessions. + /// + /// The `source` argument controls where the image comes from. This can + /// be either [ImageSource.camera] or [ImageSource.gallery]. + /// + /// Where iOS supports HEIC images, Android 8 and below doesn't. Android 9 and above only support HEIC images if used + /// in addition to a size modification, of which the usage is explained below. + /// + /// If specified, the image will be at most `maxWidth` wide and + /// `maxHeight` tall. Otherwise the image will be returned at it's + /// original width and height. + /// The `imageQuality` argument modifies the quality of the image, ranging from 0-100 + /// where 100 is the original/max quality. If `imageQuality` is null, the image with + /// the original quality will be returned. Compression is only supported for certain + /// image types such as JPEG and on Android PNG and WebP, too. If compression is not supported for the image that is picked, + /// a warning message will be logged. + /// + /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. + /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. + /// Defaults to [CameraDevice.rear]. Note that Android has no documented parameter for an intent to specify if + /// the front or rear camera should be opened, this function is not guaranteed + /// to work on an Android device. + /// + /// In Android, the MainActivity can be destroyed for various reasons. If that happens, the result will be lost + /// in this call. You can then call [retrieveLostData] when your app relaunches to retrieve the lost data. + /// + /// See also [pickMultiImage] to allow users to select multiple images at once. + /// + /// The method could throw [PlatformException] if the app does not have permission to access + /// the camera or photos gallery, no camera is available, plugin is already in use, + /// temporary file could not be created (iOS only), plugin activity could not + /// be allocated (Android only) or due to an unknown error. + Future pickImage({ + required ImageSource source, + double? maxWidth, + double? maxHeight, + int? imageQuality, + CameraDevice preferredCameraDevice = CameraDevice.rear, + }) { + return platform.getImage( + source: source, + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + preferredCameraDevice: preferredCameraDevice, + ); + } + + /// Returns a [List] object wrapping the images that were picked. + /// + /// The returned [List] is intended to be used within a single APP session. Do not save the file path and use it across sessions. + /// + /// Where iOS supports HEIC images, Android 8 and below doesn't. Android 9 and above only support HEIC images if used + /// in addition to a size modification, of which the usage is explained below. + /// + /// This method is not supported in iOS versions lower than 14. + /// + /// If specified, the images will be at most `maxWidth` wide and + /// `maxHeight` tall. Otherwise the images will be returned at it's + /// original width and height. + /// The `imageQuality` argument modifies the quality of the images, ranging from 0-100 + /// where 100 is the original/max quality. If `imageQuality` is null, the images with + /// the original quality will be returned. Compression is only supported for certain + /// image types such as JPEG and on Android PNG and WebP, too. If compression is not supported for the image that is picked, + /// a warning message will be logged. + /// + /// The method could throw [PlatformException] if the app does not have permission to access + /// the camera or photos gallery, no camera is available, plugin is already in use, + /// temporary file could not be created (iOS only), plugin activity could not + /// be allocated (Android only) or due to an unknown error. + /// + /// See also [pickImage] to allow users to only pick a single image. + Future?> pickMultiImage({ + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) { + return platform.getMultiImage( + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + ); + } + + /// Returns an [XFile] object wrapping the video that was picked. + /// + /// The returned [XFile] is intended to be used within a single APP session. Do not save the file path and use it across sessions. + /// + /// The [source] argument controls where the video comes from. This can + /// be either [ImageSource.camera] or [ImageSource.gallery]. + /// + /// The [maxDuration] argument specifies the maximum duration of the captured video. If no [maxDuration] is specified, + /// the maximum duration will be infinite. + /// + /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. + /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. + /// Defaults to [CameraDevice.rear]. + /// + /// In Android, the MainActivity can be destroyed for various fo reasons. If that happens, the result will be lost + /// in this call. You can then call [retrieveLostData] when your app relaunches to retrieve the lost data. + /// + /// The method could throw [PlatformException] if the app does not have permission to access + /// the camera or photos gallery, no camera is available, plugin is already in use, + /// temporary file could not be created and video could not be cached (iOS only), + /// plugin activity could not be allocated (Android only) or due to an unknown error. + /// + Future pickVideo({ + required ImageSource source, + CameraDevice preferredCameraDevice = CameraDevice.rear, + Duration? maxDuration, + }) { + return platform.getVideo( + source: source, + preferredCameraDevice: preferredCameraDevice, + maxDuration: maxDuration, + ); + } + + /// Retrieve the lost [XFile] when [pickImage], [pickMultiImage] or [pickVideo] failed because the MainActivity + /// is destroyed. (Android only) + /// + /// Image or video can be lost if the MainActivity is destroyed. And there is no guarantee that the MainActivity is always alive. + /// Call this method to retrieve the lost data and process the data according to your APP's business logic. + /// + /// Returns a [LostDataResponse] object if successfully retrieved the lost data. The [LostDataResponse] object can \ + /// represent either a successful image/video selection, or a failure. + /// + /// Calling this on a non-Android platform will throw [UnimplementedError] exception. + /// + /// See also: + /// * [LostDataResponse], for what's included in the response. + /// * [Android Activity Lifecycle](https://developer.android.com/reference/android/app/Activity.html), for more information on MainActivity destruction. + Future retrieveLostData() { + return platform.getLostData(); + } } diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index c9866dbcda02..e5ecfeb22232 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.1+4 +version: 0.8.2 environment: sdk: ">=2.12.0 <3.0.0" @@ -24,8 +24,8 @@ dependencies: flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 - image_picker_for_web: ^2.0.0 - image_picker_platform_interface: ^2.1.0 + image_picker_for_web: ^2.1.0 + image_picker_platform_interface: ^2.2.0 dev_dependencies: flutter_test: diff --git a/packages/image_picker/image_picker/test/image_picker_deprecated_test.dart b/packages/image_picker/image_picker/test/image_picker_deprecated_test.dart new file mode 100644 index 000000000000..f295e3d02f66 --- /dev/null +++ b/packages/image_picker/image_picker/test/image_picker_deprecated_test.dart @@ -0,0 +1,458 @@ +// Copyright 2013 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. + +// ignore_for_file: deprecated_member_use_from_same_package + +// This file preserves the tests for the deprecated methods as they were before +// the migration. See image_picker_test.dart for the current tests. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:mockito/mockito.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$ImagePicker', () { + const MethodChannel channel = + MethodChannel('plugins.flutter.io/image_picker'); + + final List log = []; + + final picker = ImagePicker(); + + test('ImagePicker platform instance overrides the actual platform used', + () { + final ImagePickerPlatform savedPlatform = ImagePickerPlatform.instance; + final MockPlatform mockPlatform = MockPlatform(); + ImagePickerPlatform.instance = mockPlatform; + expect(ImagePicker.platform, mockPlatform); + ImagePickerPlatform.instance = savedPlatform; + }); + + group('#Single image/video', () { + setUp(() { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + return ''; + }); + + log.clear(); + }); + + group('#pickImage', () { + test('passes the image source argument correctly', () async { + await picker.getImage(source: ImageSource.camera); + await picker.getImage(source: ImageSource.gallery); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 1, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + ], + ); + }); + + test('passes the width and height arguments correctly', () async { + await picker.getImage(source: ImageSource.camera); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + ); + await picker.getImage( + source: ImageSource.camera, + maxHeight: 10.0, + ); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + maxHeight: 20.0, + ); + await picker.getImage( + source: ImageSource.camera, maxWidth: 10.0, imageQuality: 70); + await picker.getImage( + source: ImageSource.camera, maxHeight: 10.0, imageQuality: 70); + await picker.getImage( + source: ImageSource.camera, + maxWidth: 10.0, + maxHeight: 20.0, + imageQuality: 70); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': 70, + 'cameraDevice': 0 + }), + ], + ); + }); + + test('does not accept a negative width or height argument', () { + expect( + picker.getImage(source: ImageSource.camera, maxWidth: -1.0), + throwsArgumentError, + ); + + expect( + picker.getImage(source: ImageSource.camera, maxHeight: -1.0), + throwsArgumentError, + ); + }); + + test('handles a null image path response gracefully', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getImage(source: ImageSource.gallery), isNull); + expect(await picker.getImage(source: ImageSource.camera), isNull); + }); + + test('camera position defaults to back', () async { + await picker.getImage(source: ImageSource.camera); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 0, + }), + ], + ); + }); + + test('camera position can set to front', () async { + await picker.getImage( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.front); + + expect( + log, + [ + isMethodCall('pickImage', arguments: { + 'source': 0, + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + 'cameraDevice': 1, + }), + ], + ); + }); + }); + + group('#pickVideo', () { + test('passes the image source argument correctly', () async { + await picker.getVideo(source: ImageSource.camera); + await picker.getVideo(source: ImageSource.gallery); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'cameraDevice': 0, + 'maxDuration': null, + }), + isMethodCall('pickVideo', arguments: { + 'source': 1, + 'cameraDevice': 0, + 'maxDuration': null, + }), + ], + ); + }); + + test('passes the duration argument correctly', () async { + await picker.getVideo(source: ImageSource.camera); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(seconds: 10)); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(minutes: 1)); + await picker.getVideo( + source: ImageSource.camera, + maxDuration: const Duration(hours: 1)); + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': null, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 10, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 60, + 'cameraDevice': 0, + }), + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': 3600, + 'cameraDevice': 0, + }), + ], + ); + }); + + test('handles a null video path response gracefully', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getVideo(source: ImageSource.gallery), isNull); + expect(await picker.getVideo(source: ImageSource.camera), isNull); + }); + + test('camera position defaults to back', () async { + await picker.getVideo(source: ImageSource.camera); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'cameraDevice': 0, + 'maxDuration': null, + }), + ], + ); + }); + + test('camera position can set to front', () async { + await picker.getVideo( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.front); + + expect( + log, + [ + isMethodCall('pickVideo', arguments: { + 'source': 0, + 'maxDuration': null, + 'cameraDevice': 1, + }), + ], + ); + }); + }); + + group('#retrieveLostData', () { + test('retrieveLostData get success response', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'image', + 'path': '/example/path', + }; + }); + final LostData response = await picker.getLostData(); + expect(response.type, RetrieveType.image); + expect(response.file!.path, '/example/path'); + }); + + test('retrieveLostData get error response', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'video', + 'errorCode': 'test_error_code', + 'errorMessage': 'test_error_message', + }; + }); + final LostData response = await picker.getLostData(); + expect(response.type, RetrieveType.video); + expect(response.exception!.code, 'test_error_code'); + expect(response.exception!.message, 'test_error_message'); + }); + + test('retrieveLostData get null response', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return null; + }); + expect((await picker.getLostData()).isEmpty, true); + }); + + test('retrieveLostData get both path and error should throw', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'video', + 'errorCode': 'test_error_code', + 'errorMessage': 'test_error_message', + 'path': '/example/path', + }; + }); + expect(picker.getLostData(), throwsAssertionError); + }); + }); + }); + + group('Multi images', () { + setUp(() { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + return []; + }); + log.clear(); + }); + + group('#pickMultiImage', () { + test('passes the width and height arguments correctly', () async { + await picker.getMultiImage(); + await picker.getMultiImage( + maxWidth: 10.0, + ); + await picker.getMultiImage( + maxHeight: 10.0, + ); + await picker.getMultiImage( + maxWidth: 10.0, + maxHeight: 20.0, + ); + await picker.getMultiImage( + maxWidth: 10.0, + imageQuality: 70, + ); + await picker.getMultiImage( + maxHeight: 10.0, + imageQuality: 70, + ); + await picker.getMultiImage( + maxWidth: 10.0, maxHeight: 20.0, imageQuality: 70); + + expect( + log, + [ + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': null, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': null, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': null, + 'imageQuality': 70, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': null, + 'maxHeight': 10.0, + 'imageQuality': 70, + }), + isMethodCall('pickMultiImage', arguments: { + 'maxWidth': 10.0, + 'maxHeight': 20.0, + 'imageQuality': 70, + }), + ], + ); + }); + + test('does not accept a negative width or height argument', () { + expect( + picker.getMultiImage(maxWidth: -1.0), + throwsArgumentError, + ); + + expect( + picker.getMultiImage(maxHeight: -1.0), + throwsArgumentError, + ); + }); + + test('handles a null image path response gracefully', () async { + channel.setMockMethodCallHandler((MethodCall methodCall) => null); + + expect(await picker.getMultiImage(), isNull); + expect(await picker.getMultiImage(), isNull); + }); + }); + }); + }); +} + +class MockPlatform extends Mock + with MockPlatformInterfaceMixin + implements ImagePickerPlatform {} diff --git a/packages/image_picker/image_picker/test/image_picker_test.dart b/packages/image_picker/image_picker/test/image_picker_test.dart index d83b403d1d45..960dfe6917ea 100644 --- a/packages/image_picker/image_picker/test/image_picker_test.dart +++ b/packages/image_picker/image_picker/test/image_picker_test.dart @@ -41,8 +41,8 @@ void main() { group('#pickImage', () { test('passes the image source argument correctly', () async { - await picker.getImage(source: ImageSource.camera); - await picker.getImage(source: ImageSource.gallery); + await picker.pickImage(source: ImageSource.camera); + await picker.pickImage(source: ImageSource.gallery); expect( log, @@ -66,25 +66,25 @@ void main() { }); test('passes the width and height arguments correctly', () async { - await picker.getImage(source: ImageSource.camera); - await picker.getImage( + await picker.pickImage(source: ImageSource.camera); + await picker.pickImage( source: ImageSource.camera, maxWidth: 10.0, ); - await picker.getImage( + await picker.pickImage( source: ImageSource.camera, maxHeight: 10.0, ); - await picker.getImage( + await picker.pickImage( source: ImageSource.camera, maxWidth: 10.0, maxHeight: 20.0, ); - await picker.getImage( + await picker.pickImage( source: ImageSource.camera, maxWidth: 10.0, imageQuality: 70); - await picker.getImage( + await picker.pickImage( source: ImageSource.camera, maxHeight: 10.0, imageQuality: 70); - await picker.getImage( + await picker.pickImage( source: ImageSource.camera, maxWidth: 10.0, maxHeight: 20.0, @@ -148,12 +148,12 @@ void main() { test('does not accept a negative width or height argument', () { expect( - picker.getImage(source: ImageSource.camera, maxWidth: -1.0), + picker.pickImage(source: ImageSource.camera, maxWidth: -1.0), throwsArgumentError, ); expect( - picker.getImage(source: ImageSource.camera, maxHeight: -1.0), + picker.pickImage(source: ImageSource.camera, maxHeight: -1.0), throwsArgumentError, ); }); @@ -161,12 +161,12 @@ void main() { test('handles a null image path response gracefully', () async { channel.setMockMethodCallHandler((MethodCall methodCall) => null); - expect(await picker.getImage(source: ImageSource.gallery), isNull); - expect(await picker.getImage(source: ImageSource.camera), isNull); + expect(await picker.pickImage(source: ImageSource.gallery), isNull); + expect(await picker.pickImage(source: ImageSource.camera), isNull); }); test('camera position defaults to back', () async { - await picker.getImage(source: ImageSource.camera); + await picker.pickImage(source: ImageSource.camera); expect( log, @@ -183,7 +183,7 @@ void main() { }); test('camera position can set to front', () async { - await picker.getImage( + await picker.pickImage( source: ImageSource.camera, preferredCameraDevice: CameraDevice.front); @@ -204,8 +204,8 @@ void main() { group('#pickVideo', () { test('passes the image source argument correctly', () async { - await picker.getVideo(source: ImageSource.camera); - await picker.getVideo(source: ImageSource.gallery); + await picker.pickVideo(source: ImageSource.camera); + await picker.pickVideo(source: ImageSource.gallery); expect( log, @@ -225,14 +225,14 @@ void main() { }); test('passes the duration argument correctly', () async { - await picker.getVideo(source: ImageSource.camera); - await picker.getVideo( + await picker.pickVideo(source: ImageSource.camera); + await picker.pickVideo( source: ImageSource.camera, maxDuration: const Duration(seconds: 10)); - await picker.getVideo( + await picker.pickVideo( source: ImageSource.camera, maxDuration: const Duration(minutes: 1)); - await picker.getVideo( + await picker.pickVideo( source: ImageSource.camera, maxDuration: const Duration(hours: 1)); expect( @@ -265,12 +265,12 @@ void main() { test('handles a null video path response gracefully', () async { channel.setMockMethodCallHandler((MethodCall methodCall) => null); - expect(await picker.getVideo(source: ImageSource.gallery), isNull); - expect(await picker.getVideo(source: ImageSource.camera), isNull); + expect(await picker.pickVideo(source: ImageSource.gallery), isNull); + expect(await picker.pickVideo(source: ImageSource.camera), isNull); }); test('camera position defaults to back', () async { - await picker.getVideo(source: ImageSource.camera); + await picker.pickVideo(source: ImageSource.camera); expect( log, @@ -285,7 +285,7 @@ void main() { }); test('camera position can set to front', () async { - await picker.getVideo( + await picker.pickVideo( source: ImageSource.camera, preferredCameraDevice: CameraDevice.front); @@ -310,7 +310,7 @@ void main() { 'path': '/example/path', }; }); - final LostData response = await picker.getLostData(); + final LostDataResponse response = await picker.retrieveLostData(); expect(response.type, RetrieveType.image); expect(response.file!.path, '/example/path'); }); @@ -323,7 +323,7 @@ void main() { 'errorMessage': 'test_error_message', }; }); - final LostData response = await picker.getLostData(); + final LostDataResponse response = await picker.retrieveLostData(); expect(response.type, RetrieveType.video); expect(response.exception!.code, 'test_error_code'); expect(response.exception!.message, 'test_error_message'); @@ -333,7 +333,7 @@ void main() { channel.setMockMethodCallHandler((MethodCall methodCall) async { return null; }); - expect((await picker.getLostData()).isEmpty, true); + expect((await picker.retrieveLostData()).isEmpty, true); }); test('retrieveLostData get both path and error should throw', () async { @@ -345,12 +345,12 @@ void main() { 'path': '/example/path', }; }); - expect(picker.getLostData(), throwsAssertionError); + expect(picker.retrieveLostData(), throwsAssertionError); }); }); }); - group('Multi images', () { + group('#Multi images', () { setUp(() { channel.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); @@ -361,26 +361,26 @@ void main() { group('#pickMultiImage', () { test('passes the width and height arguments correctly', () async { - await picker.getMultiImage(); - await picker.getMultiImage( + await picker.pickMultiImage(); + await picker.pickMultiImage( maxWidth: 10.0, ); - await picker.getMultiImage( + await picker.pickMultiImage( maxHeight: 10.0, ); - await picker.getMultiImage( + await picker.pickMultiImage( maxWidth: 10.0, maxHeight: 20.0, ); - await picker.getMultiImage( + await picker.pickMultiImage( maxWidth: 10.0, imageQuality: 70, ); - await picker.getMultiImage( + await picker.pickMultiImage( maxHeight: 10.0, imageQuality: 70, ); - await picker.getMultiImage( + await picker.pickMultiImage( maxWidth: 10.0, maxHeight: 20.0, imageQuality: 70); expect( @@ -427,12 +427,12 @@ void main() { test('does not accept a negative width or height argument', () { expect( - picker.getMultiImage(maxWidth: -1.0), + picker.pickMultiImage(maxWidth: -1.0), throwsArgumentError, ); expect( - picker.getMultiImage(maxHeight: -1.0), + picker.pickMultiImage(maxHeight: -1.0), throwsArgumentError, ); }); @@ -440,8 +440,8 @@ void main() { test('handles a null image path response gracefully', () async { channel.setMockMethodCallHandler((MethodCall methodCall) => null); - expect(await picker.getMultiImage(), isNull); - expect(await picker.getMultiImage(), isNull); + expect(await picker.pickMultiImage(), isNull); + expect(await picker.pickMultiImage(), isNull); }); }); }); From f726c319a61dd50662cdf5a52e9e4ab12b6ec2c8 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Fri, 16 Jul 2021 11:16:03 +0200 Subject: [PATCH 133/364] [camera] Fix coordinate rotation for setting focus- and exposure points on iOS (#4158) --- packages/camera/camera/CHANGELOG.md | 4 ++ .../ios/RunnerTests/CameraExposureTests.m | 55 +++++++++++++++++++ .../ios/RunnerTests/CameraFocusTests.m | 27 ++++++++- .../example/ios/RunnerTests/CameraUtilTests.m | 49 +++++++++++++++++ .../camera/camera/ios/Classes/CameraPlugin.m | 37 ++++++++++++- packages/camera/camera/pubspec.yaml | 2 +- 6 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 packages/camera/camera/example/ios/RunnerTests/CameraExposureTests.m create mode 100644 packages/camera/camera/example/ios/RunnerTests/CameraUtilTests.m diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 1f30104218e3..236cf96f027a 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1+5 + +* Make sure the `setFocusPoint` and `setExposurePoint` coordinates work correctly in all orientations on iOS (instead of only in portrait mode). + ## 0.8.1+4 * Silenced warnings that may occur during build when using a very diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraExposureTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraExposureTests.m new file mode 100644 index 000000000000..ee43d3f155f4 --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraExposureTests.m @@ -0,0 +1,55 @@ +// Copyright 2013 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 camera; +@import XCTest; +@import AVFoundation; +#import + +@interface FLTCam : NSObject + +- (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y; +@end + +@interface CameraExposureTests : XCTestCase +@property(readonly, nonatomic) FLTCam *camera; +@property(readonly, nonatomic) id mockDevice; +@property(readonly, nonatomic) id mockUIDevice; +@end + +@implementation CameraExposureTests + +- (void)setUp { + _camera = [[FLTCam alloc] init]; + _mockDevice = OCMClassMock([AVCaptureDevice class]); + _mockUIDevice = OCMPartialMock([UIDevice currentDevice]); +} + +- (void)tearDown { + [_mockDevice stopMocking]; + [_mockUIDevice stopMocking]; +} + +- (void)testSetExpsourePointWithResult_SetsExposurePointOfInterest { + // UI is currently in landscape left orientation + OCMStub([(UIDevice *)_mockUIDevice orientation]).andReturn(UIDeviceOrientationLandscapeLeft); + // Exposure point of interest is supported + OCMStub([_mockDevice isExposurePointOfInterestSupported]).andReturn(true); + // Set mock device as the current capture device + [_camera setValue:_mockDevice forKey:@"captureDevice"]; + + // Run test + [_camera + setExposurePointWithResult:^void(id _Nullable result) { + } + x:1 + y:1]; + + // Verify the focus point of interest has been set + OCMVerify([_mockDevice setExposurePointOfInterest:CGPointMake(1, 1)]); +} + +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m index 5d93bdf70332..27537e7ebdac 100644 --- a/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m +++ b/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m @@ -19,12 +19,13 @@ @interface FLTCam : NSObject + +@interface FLTCam : NSObject + +- (CGPoint)getCGPointForCoordsWithOrientation:(UIDeviceOrientation)orientation + x:(double)x + y:(double)y; + +@end + +@interface CameraUtilTests : XCTestCase +@property(readonly, nonatomic) FLTCam *camera; + +@end + +@implementation CameraUtilTests + +- (void)setUp { + _camera = [[FLTCam alloc] init]; +} + +- (void)testGetCGPointForCoordsWithOrientation_ShouldRotateCoords { + CGPoint point; + point = [_camera getCGPointForCoordsWithOrientation:UIDeviceOrientationLandscapeLeft x:1 y:1]; + XCTAssertTrue(CGPointEqualToPoint(point, CGPointMake(1, 1)), + @"Resulting coordinates are invalid."); + point = [_camera getCGPointForCoordsWithOrientation:UIDeviceOrientationPortrait x:0 y:1]; + XCTAssertTrue(CGPointEqualToPoint(point, CGPointMake(1, 1)), + @"Resulting coordinates are invalid."); + point = [_camera getCGPointForCoordsWithOrientation:UIDeviceOrientationLandscapeRight x:0 y:0]; + XCTAssertTrue(CGPointEqualToPoint(point, CGPointMake(1, 1)), + @"Resulting coordinates are invalid."); + point = [_camera getCGPointForCoordsWithOrientation:UIDeviceOrientationPortraitUpsideDown + x:1 + y:0]; + XCTAssertTrue(CGPointEqualToPoint(point, CGPointMake(1, 1)), + @"Resulting coordinates are invalid."); +} + +@end diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index ebd5366ba78d..d88eb45945fe 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -1030,6 +1030,31 @@ - (void)applyFocusMode:(FocusMode)focusMode onDevice:(AVCaptureDevice *)captureD [captureDevice unlockForConfiguration]; } +- (CGPoint)getCGPointForCoordsWithOrientation:(UIDeviceOrientation)orientation + x:(double)x + y:(double)y { + double oldX = x, oldY = y; + switch (orientation) { + case UIDeviceOrientationPortrait: // 90 ccw + y = 1 - oldX; + x = oldY; + break; + case UIDeviceOrientationPortraitUpsideDown: // 90 cw + x = 1 - oldY; + y = oldX; + break; + case UIDeviceOrientationLandscapeRight: // 180 + x = 1 - x; + y = 1 - y; + break; + case UIDeviceOrientationLandscapeLeft: + default: + // No rotation required + break; + } + return CGPointMake(x, y); +} + - (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y { if (!_captureDevice.isExposurePointOfInterestSupported) { result([FlutterError errorWithCode:@"setExposurePointFailed" @@ -1037,8 +1062,11 @@ - (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y details:nil]); return; } + UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation]; [_captureDevice lockForConfiguration:nil]; - [_captureDevice setExposurePointOfInterest:CGPointMake(y, 1 - x)]; + [_captureDevice setExposurePointOfInterest:[self getCGPointForCoordsWithOrientation:orientation + x:x + y:y]]; [_captureDevice unlockForConfiguration]; // Retrigger auto exposure [self applyExposureMode]; @@ -1052,11 +1080,16 @@ - (void)setFocusPointWithResult:(FlutterResult)result x:(double)x y:(double)y { details:nil]); return; } + UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation]; [_captureDevice lockForConfiguration:nil]; - [_captureDevice setFocusPointOfInterest:CGPointMake(y, 1 - x)]; + + [_captureDevice setFocusPointOfInterest:[self getCGPointForCoordsWithOrientation:orientation + x:x + y:y]]; [_captureDevice unlockForConfiguration]; // Retrigger auto focus [self applyFocusMode]; + result(nil); } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 789910e2c79b..78eb49a999a2 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.8.1+4 +version: 0.8.1+5 environment: sdk: ">=2.12.0 <3.0.0" From 57fb51f06e18fcecbdf7d15cb60ff59a7671d150 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Sat, 17 Jul 2021 01:03:05 +0200 Subject: [PATCH 134/364] [camera] Introduce `camera_web` package (#4151) This first version is a no-op implementation of the platform_interface, but is the initial step to bring over the implementation from the Photobooth into flutter/plugins. Won't be published (for now). Co-authored-by: Felix Angelov --- packages/camera/camera_web/CHANGELOG.md | 3 + packages/camera/camera_web/LICENSE | 25 ++ packages/camera/camera_web/README.md | 7 + packages/camera/camera_web/example/README.md | 9 + .../integration_test/camera_web_test.dart | 314 ++++++++++++++++++ .../integration_test/helpers/helpers.dart | 5 + .../integration_test/helpers/mocks.dart | 23 ++ .../camera/camera_web/example/lib/main.dart | 18 + .../camera/camera_web/example/pubspec.yaml | 21 ++ .../camera/camera_web/example/run_test.sh | 22 ++ .../example/test_driver/integration_test.dart | 7 + .../camera/camera_web/example/web/index.html | 12 + .../camera/camera_web/lib/camera_web.dart | 7 + .../camera/camera_web/lib/src/camera_web.dart | 193 +++++++++++ packages/camera/camera_web/pubspec.yaml | 33 ++ packages/camera/camera_web/test/README.md | 5 + .../test/tests_exist_elsewhere_test.dart | 14 + 17 files changed, 718 insertions(+) create mode 100644 packages/camera/camera_web/CHANGELOG.md create mode 100644 packages/camera/camera_web/LICENSE create mode 100644 packages/camera/camera_web/README.md create mode 100644 packages/camera/camera_web/example/README.md create mode 100644 packages/camera/camera_web/example/integration_test/camera_web_test.dart create mode 100644 packages/camera/camera_web/example/integration_test/helpers/helpers.dart create mode 100644 packages/camera/camera_web/example/integration_test/helpers/mocks.dart create mode 100644 packages/camera/camera_web/example/lib/main.dart create mode 100644 packages/camera/camera_web/example/pubspec.yaml create mode 100755 packages/camera/camera_web/example/run_test.sh create mode 100644 packages/camera/camera_web/example/test_driver/integration_test.dart create mode 100644 packages/camera/camera_web/example/web/index.html create mode 100644 packages/camera/camera_web/lib/camera_web.dart create mode 100644 packages/camera/camera_web/lib/src/camera_web.dart create mode 100644 packages/camera/camera_web/pubspec.yaml create mode 100644 packages/camera/camera_web/test/README.md create mode 100644 packages/camera/camera_web/test/tests_exist_elsewhere_test.dart diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md new file mode 100644 index 000000000000..1318780830f8 --- /dev/null +++ b/packages/camera/camera_web/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +* Initial release diff --git a/packages/camera/camera_web/LICENSE b/packages/camera/camera_web/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/camera/camera_web/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md new file mode 100644 index 000000000000..d57bd7446d17 --- /dev/null +++ b/packages/camera/camera_web/README.md @@ -0,0 +1,7 @@ +# Camera Web Plugin + +A Flutter plugin for Web allowing access to the device cameras. + +*Note*: This plugin is under development. + +In order to use this plugin, your app should depend both on `camera` and `camera_web`. This is a temporary solution until a plugin is released. \ No newline at end of file diff --git a/packages/camera/camera_web/example/README.md b/packages/camera/camera_web/example/README.md new file mode 100644 index 000000000000..8a6e74b107ea --- /dev/null +++ b/packages/camera/camera_web/example/README.md @@ -0,0 +1,9 @@ +# Testing + +This package uses `package:integration_test` to run its tests in a web browser. + +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. + +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. \ No newline at end of file diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart new file mode 100644 index 000000000000..d26f0e855889 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -0,0 +1,314 @@ +// Copyright 2013 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:html'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/camera_web.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('CameraPlugin', () { + const cameraId = 0; + + late Window window; + late Navigator navigator; + late MediaDevices mediaDevices; + late VideoElement videoElement; + + setUp(() async { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + videoElement = VideoElement() + ..src = + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' + ..preload = 'true' + ..width = 10 + ..height = 10; + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + when( + () => mediaDevices.getUserMedia(any()), + ).thenAnswer((_) async => videoElement.captureStream()); + + CameraPlatform.instance = CameraPlugin()..window = window; + }); + + testWidgets('CameraPlugin is the live instance', (tester) async { + expect(CameraPlatform.instance, isA()); + }); + + testWidgets('availableCameras throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.availableCameras(), + throwsUnimplementedError, + ); + }); + + testWidgets('createCamera throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.createCamera( + CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.external, + sensorOrientation: 0, + ), + ResolutionPreset.medium, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('initializeCamera throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('lockCaptureOrientation throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.landscapeLeft, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('unlockCaptureOrientation throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.unlockCaptureOrientation(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('takePicture throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.takePicture(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('prepareForVideoRecording throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.prepareForVideoRecording(), + throwsUnimplementedError, + ); + }); + + testWidgets('startVideoRecording throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('stopVideoRecording throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('pauseVideoRecording throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('resumeVideoRecording throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('setFlashMode throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setFlashMode( + cameraId, + FlashMode.auto, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('setExposureMode throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setExposureMode( + cameraId, + ExposureMode.auto, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('setExposurePoint throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setExposurePoint( + cameraId, + const Point(0, 0), + ), + throwsUnimplementedError, + ); + }); + + testWidgets('getMinExposureOffset throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.getMinExposureOffset(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('getMaxExposureOffset throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.getMaxExposureOffset(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('getExposureOffsetStepSize throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.getExposureOffsetStepSize(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('setExposureOffset throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setExposureOffset( + cameraId, + 0, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('setFocusMode throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setFocusMode( + cameraId, + FocusMode.auto, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('setFocusPoint throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setFocusPoint( + cameraId, + const Point(0, 0), + ), + throwsUnimplementedError, + ); + }); + + testWidgets('getMaxZoomLevel throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.getMaxZoomLevel(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('getMinZoomLevel throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.getMinZoomLevel(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('setZoomLevel throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.setZoomLevel( + cameraId, + 1.0, + ), + throwsUnimplementedError, + ); + }); + + testWidgets('buildPreview throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.buildPreview(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('dispose throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.dispose(cameraId), + throwsUnimplementedError, + ); + }); + + group('events', () { + testWidgets('onCameraInitialized throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.onCameraInitialized(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('onCameraResolutionChanged throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.onCameraResolutionChanged(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('onCameraClosing throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.onCameraClosing(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('onCameraError throws UnimplementedError', (tester) async { + expect( + () => CameraPlatform.instance.onCameraError(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('onVideoRecordedEvent throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.onVideoRecordedEvent(cameraId), + throwsUnimplementedError, + ); + }); + + testWidgets('onDeviceOrientationChanged throws UnimplementedError', + (tester) async { + expect( + () => CameraPlatform.instance.onDeviceOrientationChanged(), + throwsUnimplementedError, + ); + }); + }); + }); +} diff --git a/packages/camera/camera_web/example/integration_test/helpers/helpers.dart b/packages/camera/camera_web/example/integration_test/helpers/helpers.dart new file mode 100644 index 000000000000..7094f55bb62e --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/helpers/helpers.dart @@ -0,0 +1,5 @@ +// Copyright 2013 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. + +export 'mocks.dart'; diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart new file mode 100644 index 000000000000..03be3f0b3ca6 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -0,0 +1,23 @@ +// Copyright 2013 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:html'; + +import 'package:mocktail/mocktail.dart'; + +class MockWindow extends Mock implements Window {} + +class MockNavigator extends Mock implements Navigator {} + +class MockMediaDevices extends Mock implements MediaDevices {} + +/// A fake [DomException] that returns the provided [errorName]. +class FakeDomException extends Fake implements DomException { + FakeDomException(this.errorName); + + final String errorName; + + @override + String get name => errorName; +} diff --git a/packages/camera/camera_web/example/lib/main.dart b/packages/camera/camera_web/example/lib/main.dart new file mode 100644 index 000000000000..6e8f85e74f40 --- /dev/null +++ b/packages/camera/camera_web/example/lib/main.dart @@ -0,0 +1,18 @@ +// Copyright 2013 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 'package:flutter/material.dart'; + +void main() => runApp(MyApp()); + +/// App for testing +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Text('Testing... Look at the console output for results!'), + ); + } +} diff --git a/packages/camera/camera_web/example/pubspec.yaml b/packages/camera/camera_web/example/pubspec.yaml new file mode 100644 index 000000000000..1e075712325e --- /dev/null +++ b/packages/camera/camera_web/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: camera_web_integration_tests +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + mocktail: ^0.1.4 + camera_web: + path: ../ + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + integration_test: + sdk: flutter diff --git a/packages/camera/camera_web/example/run_test.sh b/packages/camera/camera_web/example/run_test.sh new file mode 100755 index 000000000000..00482faa53df --- /dev/null +++ b/packages/camera/camera_web/example/run_test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Copyright 2013 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. + +if pgrep -lf chromedriver > /dev/null; then + echo "chromedriver is running." + + if [ $# -eq 0 ]; then + echo "No target specified, running all tests..." + find integration_test/ -iname *_test.dart | xargs -n1 -I{} -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + else + echo "Running test target: $1..." + set -x + flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target=$1 + fi + + else + echo "chromedriver is not running." + echo "Please, check the README.md for instructions on how to use run_test.sh" +fi + diff --git a/packages/camera/camera_web/example/test_driver/integration_test.dart b/packages/camera/camera_web/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/camera/camera_web/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/camera/camera_web/example/web/index.html b/packages/camera/camera_web/example/web/index.html new file mode 100644 index 000000000000..f3c6a5e8a8e3 --- /dev/null +++ b/packages/camera/camera_web/example/web/index.html @@ -0,0 +1,12 @@ + + + + + Browser Tests + + + + + diff --git a/packages/camera/camera_web/lib/camera_web.dart b/packages/camera/camera_web/lib/camera_web.dart new file mode 100644 index 000000000000..dcefc9293b88 --- /dev/null +++ b/packages/camera/camera_web/lib/camera_web.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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. + +library camera_web; + +export 'src/camera_web.dart'; diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart new file mode 100644 index 000000000000..fc3be09eec1d --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -0,0 +1,193 @@ +// Copyright 2013 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:async'; +import 'dart:html' as html; +import 'dart:math'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +/// The web implementation of [CameraPlatform]. +/// +/// This class implements the `package:camera` functionality for the web. +class CameraPlugin extends CameraPlatform { + /// Registers this class as the default instance of [CameraPlatform]. + static void registerWith(Registrar registrar) { + CameraPlatform.instance = CameraPlugin(); + } + + /// The current browser window used to access device cameras. + @visibleForTesting + html.Window? window; + + @override + Future> availableCameras() { + throw UnimplementedError('availableCameras() is not implemented.'); + } + + @override + Future createCamera( + CameraDescription cameraDescription, + ResolutionPreset? resolutionPreset, { + bool enableAudio = false, + }) { + throw UnimplementedError('createCamera() is not implemented.'); + } + + @override + Future initializeCamera( + int cameraId, { + ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, + }) { + throw UnimplementedError('initializeCamera() is not implemented.'); + } + + @override + Stream onCameraInitialized(int cameraId) { + throw UnimplementedError('onCameraInitialized() is not implemented.'); + } + + @override + Stream onCameraResolutionChanged(int cameraId) { + throw UnimplementedError('onCameraResolutionChanged() is not implemented.'); + } + + @override + Stream onCameraClosing(int cameraId) { + throw UnimplementedError('onCameraClosing() is not implemented.'); + } + + @override + Stream onCameraError(int cameraId) { + throw UnimplementedError('onCameraError() is not implemented.'); + } + + @override + Stream onVideoRecordedEvent(int cameraId) { + throw UnimplementedError('onVideoRecordedEvent() is not implemented.'); + } + + @override + Stream onDeviceOrientationChanged() { + throw UnimplementedError( + 'onDeviceOrientationChanged() is not implemented.', + ); + } + + @override + Future lockCaptureOrientation( + int cameraId, + DeviceOrientation orientation, + ) { + throw UnimplementedError('lockCaptureOrientation() is not implemented.'); + } + + @override + Future unlockCaptureOrientation(int cameraId) { + throw UnimplementedError('unlockCaptureOrientation() is not implemented.'); + } + + @override + Future takePicture(int cameraId) { + throw UnimplementedError('takePicture() is not implemented.'); + } + + @override + Future prepareForVideoRecording() { + throw UnimplementedError('prepareForVideoRecording() is not implemented.'); + } + + @override + Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) { + throw UnimplementedError('startVideoRecording() is not implemented.'); + } + + @override + Future stopVideoRecording(int cameraId) { + throw UnimplementedError('stopVideoRecording() is not implemented.'); + } + + @override + Future pauseVideoRecording(int cameraId) { + throw UnimplementedError('pauseVideoRecording() is not implemented.'); + } + + @override + Future resumeVideoRecording(int cameraId) { + throw UnimplementedError('resumeVideoRecording() is not implemented.'); + } + + @override + Future setFlashMode(int cameraId, FlashMode mode) { + throw UnimplementedError('setFlashMode() is not implemented.'); + } + + @override + Future setExposureMode(int cameraId, ExposureMode mode) { + throw UnimplementedError('setExposureMode() is not implemented.'); + } + + @override + Future setExposurePoint(int cameraId, Point? point) { + throw UnimplementedError('setExposurePoint() is not implemented.'); + } + + @override + Future getMinExposureOffset(int cameraId) { + throw UnimplementedError('getMinExposureOffset() is not implemented.'); + } + + @override + Future getMaxExposureOffset(int cameraId) { + throw UnimplementedError('getMaxExposureOffset() is not implemented.'); + } + + @override + Future getExposureOffsetStepSize(int cameraId) { + throw UnimplementedError('getExposureOffsetStepSize() is not implemented.'); + } + + @override + Future setExposureOffset(int cameraId, double offset) { + throw UnimplementedError('setExposureOffset() is not implemented.'); + } + + @override + Future setFocusMode(int cameraId, FocusMode mode) { + throw UnimplementedError('setFocusMode() is not implemented.'); + } + + @override + Future setFocusPoint(int cameraId, Point? point) { + throw UnimplementedError('setFocusPoint() is not implemented.'); + } + + @override + Future getMaxZoomLevel(int cameraId) { + throw UnimplementedError('getMaxZoomLevel() is not implemented.'); + } + + @override + Future getMinZoomLevel(int cameraId) { + throw UnimplementedError('getMinZoomLevel() is not implemented.'); + } + + @override + Future setZoomLevel(int cameraId, double zoom) { + throw UnimplementedError('setZoomLevel() is not implemented.'); + } + + @override + Widget buildPreview(int cameraId) { + throw UnimplementedError('buildPreview() is not implemented.'); + } + + @override + Future dispose(int cameraId) { + throw UnimplementedError('dispose() is not implemented.'); + } +} diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml new file mode 100644 index 000000000000..a2aa43c22d65 --- /dev/null +++ b/packages/camera/camera_web/pubspec.yaml @@ -0,0 +1,33 @@ +name: camera_web +description: A Flutter plugin for getting information about and controlling the camera on Web. +repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 +version: 0.1.0 + +# This plugin is under development and will be published +# when the first working web camera implementation is added. +# TODO(bselwe): Remove when camera_web should be published. +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +flutter: + plugin: + platforms: + web: + pluginClass: CameraPlugin + fileName: camera_web.dart + +dependencies: + camera_platform_interface: ^2.0.1 + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + pedantic: ^1.11.1 \ No newline at end of file diff --git a/packages/camera/camera_web/test/README.md b/packages/camera/camera_web/test/README.md new file mode 100644 index 000000000000..7c5b4ad682ba --- /dev/null +++ b/packages/camera/camera_web/test/README.md @@ -0,0 +1,5 @@ +## test + +This package uses integration tests for testing. + +See `example/README.md` for more info. diff --git a/packages/camera/camera_web/test/tests_exist_elsewhere_test.dart b/packages/camera/camera_web/test/tests_exist_elsewhere_test.dart new file mode 100644 index 000000000000..442c50144727 --- /dev/null +++ b/packages/camera/camera_web/test/tests_exist_elsewhere_test.dart @@ -0,0 +1,14 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Tell the user where to find the real tests', () { + print('---'); + print('This package uses integration_test for its tests.'); + print('See `example/README.md` for more info.'); + print('---'); + }); +} From 2f5ef3df0a31ebd23daefaeced3c60d13441543b Mon Sep 17 00:00:00 2001 From: Brett Morgan Date: Tue, 20 Jul 2021 04:25:33 +1000 Subject: [PATCH 135/364] [google_maps_flutter_web]: Update installation instructions (#4163) --- .../google_maps_flutter_web/CHANGELOG.md | 4 ++++ .../google_maps_flutter_web/README.md | 9 ++------- .../google_maps_flutter_web/pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 36a4271cb95d..d587c16f9207 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.0+3 + +* Update the `README.md` usage instructions to not be tied to explicit package versions. + ## 0.3.0+2 * Document `liteModeEnabled` is not available on the web. [#83737](https://github.com/flutter/flutter/issues/83737). diff --git a/packages/google_maps_flutter/google_maps_flutter_web/README.md b/packages/google_maps_flutter/google_maps_flutter_web/README.md index cfd5f6d8271e..9e7ce94e3e59 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/README.md @@ -6,13 +6,8 @@ This is an implementation of the [google_maps_flutter](https://pub.dev/packages/ ### Depend on the package -This package is not an endorsed implementation of the google_maps_flutter plugin yet, so you'll need to modify the `pubspec.yaml` file of your app to depend on this package: - -```yaml -dependencies: - google_maps_flutter: ^0.5.28 - google_maps_flutter_web: ^0.1.0 -``` +This package is not an endorsed implementation of the google_maps_flutter plugin yet, so you'll need to +[add it explicitly](https://pub.dev/packages/google_maps_flutter_web/install). ### Modify web/index.html diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index c69b8e55fa1c..c4323fc6486f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.3.0+2 +version: 0.3.0+3 environment: sdk: ">=2.12.0 <3.0.0" From e193daf1e1bee8627fbc758fbed6a922f7a5d5ee Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Mon, 19 Jul 2021 21:51:05 +0200 Subject: [PATCH 136/364] [camera] Add `CameraOptions` used to constrain the camera audio and video (#4164) --- packages/camera/camera_web/CHANGELOG.md | 1 + .../lib/src/types/camera_options.dart | 245 ++++++++++++++++++ .../camera_web/lib/src/types/types.dart | 5 + ...t => more_tests_exist_elsewhere_test.dart} | 4 +- .../test/types/camera_options_test.dart | 200 ++++++++++++++ 5 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 packages/camera/camera_web/lib/src/types/camera_options.dart create mode 100644 packages/camera/camera_web/lib/src/types/types.dart rename packages/camera/camera_web/test/{tests_exist_elsewhere_test.dart => more_tests_exist_elsewhere_test.dart} (72%) create mode 100644 packages/camera/camera_web/test/types/camera_options_test.dart diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 1318780830f8..68bc5f4e1a1e 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,3 +1,4 @@ ## 0.1.0 * Initial release + * Added CameraOptions used to constrain the camera audio and video. diff --git a/packages/camera/camera_web/lib/src/types/camera_options.dart b/packages/camera/camera_web/lib/src/types/camera_options.dart new file mode 100644 index 000000000000..2a4cdbf15348 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_options.dart @@ -0,0 +1,245 @@ +// Copyright 2013 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:ui' show hashValues; + +/// Options used to create a camera with the given +/// [audio] and [video] media constraints. +/// +/// These options represent web `MediaStreamConstraints` +/// and can be used to request the browser for media streams +/// with audio and video tracks containing the requested types of media. +/// +/// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints +class CameraOptions { + /// Creates a new instance of [CameraOptions] + /// with the given [audio] and [video] constraints. + const CameraOptions({ + AudioConstraints? audio, + VideoConstraints? video, + }) : audio = audio ?? const AudioConstraints(), + video = video ?? const VideoConstraints(); + + /// The audio constraints for the camera. + final AudioConstraints audio; + + /// The video constraints for the camera. + final VideoConstraints video; + + /// Converts the current instance to a Map. + Map toJson() { + return { + 'audio': audio.toJson(), + 'video': video.toJson(), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is CameraOptions && + other.audio == audio && + other.video == video; + } + + @override + int get hashCode => hashValues(audio, video); +} + +/// Indicates whether the audio track is requested. +/// +/// By default, the audio track is not requested. +class AudioConstraints { + /// Creates a new instance of [AudioConstraints] + /// with the given [enabled] constraint. + const AudioConstraints({this.enabled = false}); + + /// Whether the audio track should be enabled. + final bool enabled; + + /// Converts the current instance to a Map. + Object toJson() => enabled; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is AudioConstraints && other.enabled == enabled; + } + + @override + int get hashCode => enabled.hashCode; +} + +/// Defines constraints that the video track must have +/// to be considered acceptable. +class VideoConstraints { + /// Creates a new instance of [VideoConstraints] + /// with the given constraints. + const VideoConstraints({ + this.facingMode, + this.width, + this.height, + this.deviceId, + }); + + /// The facing mode of the video track. + final FacingModeConstraint? facingMode; + + /// The width of the video track. + final VideoSizeConstraint? width; + + /// The height of the video track. + final VideoSizeConstraint? height; + + /// The device id of the video track. + final String? deviceId; + + /// Converts the current instance to a Map. + Object toJson() { + final json = {}; + + if (width != null) json['width'] = width!.toJson(); + if (height != null) json['height'] = height!.toJson(); + if (facingMode != null) json['facingMode'] = facingMode!.toJson(); + if (deviceId != null) json['deviceId'] = {'exact': deviceId!}; + + return json; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is VideoConstraints && + other.facingMode == facingMode && + other.width == width && + other.height == height && + other.deviceId == deviceId; + } + + @override + int get hashCode => hashValues(facingMode, width, height, deviceId); +} + +/// The camera type used in [FacingModeConstraint]. +/// +/// Specifies whether the requested camera should be facing away +/// or toward the user. +class CameraType { + const CameraType._(this._type); + + final String _type; + + @override + String toString() => _type; + + /// The camera is facing away from the user, viewing their environment. + /// This includes the back camera on a smartphone. + static const CameraType environment = CameraType._('environment'); + + /// The camera is facing toward the user. + /// This includes the front camera on a smartphone. + static const CameraType user = CameraType._('user'); +} + +/// Indicates the direction in which the desired camera should be pointing. +class FacingModeConstraint { + /// Creates a new instance of [FacingModeConstraint] + /// with the given [ideal] and [exact] constraints. + const FacingModeConstraint._({this.ideal, this.exact}); + + /// Creates a new instance of [FacingModeConstraint] + /// with [ideal] constraint set to [type]. + factory FacingModeConstraint(CameraType type) => + FacingModeConstraint._(ideal: type); + + /// Creates a new instance of [FacingModeConstraint] + /// with [exact] constraint set to [type]. + factory FacingModeConstraint.exact(CameraType type) => + FacingModeConstraint._(exact: type); + + /// The ideal facing mode constraint. + /// + /// If this constraint is used, then the camera would ideally have + /// the desired facing [type] but it may be considered optional. + final CameraType? ideal; + + /// The exact facing mode constraint. + /// + /// If this constraint is used, then the camera must have + /// the desired facing [type] to be considered acceptable. + final CameraType? exact; + + /// Converts the current instance to a Map. + Object? toJson() { + return { + if (ideal != null) 'ideal': ideal.toString(), + if (exact != null) 'exact': exact.toString(), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is FacingModeConstraint && + other.ideal == ideal && + other.exact == exact; + } + + @override + int get hashCode => hashValues(ideal, exact); +} + +/// The size of the requested video track used in +/// [VideoConstraints.width] and [VideoConstraints.height]. +/// +/// The obtained video track will have a size between [minimum] and [maximum] +/// with ideally a size of [ideal]. The size is determined by +/// the capabilities of the hardware and the other specified constraints. +class VideoSizeConstraint { + /// Creates a new instance of [VideoSizeConstraint] with the given + /// [minimum], [ideal] and [maximum] constraints. + const VideoSizeConstraint({this.minimum, this.ideal, this.maximum}); + + /// The minimum video size. + final int? minimum; + + /// The ideal video size. + /// + /// The video would ideally have the [ideal] size + /// but it may be considered optional. If not possible + /// to satisfy, the size will be as close as possible + /// to [ideal]. + final int? ideal; + + /// The maximum video size. + final int? maximum; + + /// Converts the current instance to a Map. + Object toJson() { + final json = {}; + + if (ideal != null) json['ideal'] = ideal; + if (minimum != null) json['min'] = minimum; + if (maximum != null) json['max'] = maximum; + + return json; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is VideoSizeConstraint && + other.minimum == minimum && + other.ideal == ideal && + other.maximum == maximum; + } + + @override + int get hashCode => hashValues(minimum, ideal, maximum); +} diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart new file mode 100644 index 000000000000..deccd32da4c0 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -0,0 +1,5 @@ +// Copyright 2013 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. + +export 'camera_options.dart'; diff --git a/packages/camera/camera_web/test/tests_exist_elsewhere_test.dart b/packages/camera/camera_web/test/more_tests_exist_elsewhere_test.dart similarity index 72% rename from packages/camera/camera_web/test/tests_exist_elsewhere_test.dart rename to packages/camera/camera_web/test/more_tests_exist_elsewhere_test.dart index 442c50144727..dc2b64c111d7 100644 --- a/packages/camera/camera_web/test/tests_exist_elsewhere_test.dart +++ b/packages/camera/camera_web/test/more_tests_exist_elsewhere_test.dart @@ -5,9 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; void main() { - test('Tell the user where to find the real tests', () { + test('Tell the user where to find more tests', () { print('---'); - print('This package uses integration_test for its tests.'); + print('This package also uses integration_test for its tests.'); print('See `example/README.md` for more info.'); print('---'); }); diff --git a/packages/camera/camera_web/test/types/camera_options_test.dart b/packages/camera/camera_web/test/types/camera_options_test.dart new file mode 100644 index 000000000000..6f60bfd5aeda --- /dev/null +++ b/packages/camera/camera_web/test/types/camera_options_test.dart @@ -0,0 +1,200 @@ +// Copyright 2013 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 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CameraOptions', () { + test('serializes correctly', () { + final cameraOptions = CameraOptions( + audio: AudioConstraints(enabled: true), + video: VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + ), + ); + + expect( + cameraOptions.toJson(), + equals({ + 'audio': cameraOptions.audio.toJson(), + 'video': cameraOptions.video.toJson(), + }), + ); + }); + + test('supports value equality', () { + expect( + CameraOptions( + audio: AudioConstraints(enabled: false), + video: VideoConstraints( + facingMode: FacingModeConstraint(CameraType.environment), + width: VideoSizeConstraint(minimum: 10, ideal: 15, maximum: 20), + height: VideoSizeConstraint(minimum: 15, ideal: 20, maximum: 25), + deviceId: 'deviceId', + ), + ), + equals( + CameraOptions( + audio: AudioConstraints(enabled: false), + video: VideoConstraints( + facingMode: FacingModeConstraint(CameraType.environment), + width: VideoSizeConstraint(minimum: 10, ideal: 15, maximum: 20), + height: VideoSizeConstraint(minimum: 15, ideal: 20, maximum: 25), + deviceId: 'deviceId', + ), + ), + ), + ); + }); + }); + + group('AudioConstraints', () { + test('serializes correctly', () { + expect( + AudioConstraints(enabled: true).toJson(), + equals(true), + ); + }); + + test('supports value equality', () { + expect( + AudioConstraints(enabled: true), + equals(AudioConstraints(enabled: true)), + ); + }); + }); + + group('VideoConstraints', () { + test('serializes correctly', () { + final videoConstraints = VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + width: VideoSizeConstraint(ideal: 100, maximum: 100), + height: VideoSizeConstraint(ideal: 50, maximum: 50), + deviceId: 'deviceId', + ); + + expect( + videoConstraints.toJson(), + equals({ + 'facingMode': videoConstraints.facingMode!.toJson(), + 'width': videoConstraints.width!.toJson(), + 'height': videoConstraints.height!.toJson(), + 'deviceId': { + 'exact': 'deviceId', + } + }), + ); + }); + + test('supports value equality', () { + expect( + VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.environment), + width: VideoSizeConstraint(minimum: 90, ideal: 100, maximum: 100), + height: VideoSizeConstraint(minimum: 40, ideal: 50, maximum: 50), + deviceId: 'deviceId', + ), + equals( + VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.environment), + width: VideoSizeConstraint(minimum: 90, ideal: 100, maximum: 100), + height: VideoSizeConstraint(minimum: 40, ideal: 50, maximum: 50), + deviceId: 'deviceId', + ), + ), + ); + }); + }); + + group('FacingModeConstraint', () { + group('ideal', () { + test( + 'serializes correctly ' + 'for environment camera type', () { + expect( + FacingModeConstraint(CameraType.environment).toJson(), + equals({'ideal': 'environment'}), + ); + }); + + test( + 'serializes correctly ' + 'for user camera type', () { + expect( + FacingModeConstraint(CameraType.user).toJson(), + equals({'ideal': 'user'}), + ); + }); + + test('supports value equality', () { + expect( + FacingModeConstraint(CameraType.user), + equals(FacingModeConstraint(CameraType.user)), + ); + }); + }); + + group('exact', () { + test( + 'serializes correctly ' + 'for environment camera type', () { + expect( + FacingModeConstraint.exact(CameraType.environment).toJson(), + equals({'exact': 'environment'}), + ); + }); + + test( + 'serializes correctly ' + 'for user camera type', () { + expect( + FacingModeConstraint.exact(CameraType.user).toJson(), + equals({'exact': 'user'}), + ); + }); + + test('supports value equality', () { + expect( + FacingModeConstraint.exact(CameraType.environment), + equals(FacingModeConstraint.exact(CameraType.environment)), + ); + }); + }); + }); + + group('VideoSizeConstraint ', () { + test('serializes correctly', () { + expect( + VideoSizeConstraint( + minimum: 200, + ideal: 400, + maximum: 400, + ).toJson(), + equals({ + 'min': 200, + 'ideal': 400, + 'max': 400, + }), + ); + }); + + test('supports value equality', () { + expect( + VideoSizeConstraint( + minimum: 100, + ideal: 200, + maximum: 300, + ), + equals( + VideoSizeConstraint( + minimum: 100, + ideal: 200, + maximum: 300, + ), + ), + ); + }); + }); +} From 027db9039c0364cc75273480602d70b58df68a53 Mon Sep 17 00:00:00 2001 From: Nishant Chandla Date: Tue, 20 Jul 2021 03:01:04 +0530 Subject: [PATCH 137/364] [multiple_web] Update web plugin testing instructions. (#4167) --- .../connectivity_for_web/example/README.md | 22 +++---------- .../file_selector_web/example/README.md | 22 +++---------- .../google_maps_flutter_web/example/README.md | 33 ++++--------------- .../google_sign_in_web/example/README.md | 22 +++---------- .../url_launcher_web/example/README.md | 33 ++++--------------- .../video_player_web/example/README.md | 22 +++---------- 6 files changed, 34 insertions(+), 120 deletions(-) diff --git a/packages/connectivity/connectivity_for_web/example/README.md b/packages/connectivity/connectivity_for_web/example/README.md index 0ec01e025570..8a6e74b107ea 100644 --- a/packages/connectivity/connectivity_for_web/example/README.md +++ b/packages/connectivity/connectivity_for_web/example/README.md @@ -1,21 +1,9 @@ # Testing -This package utilizes the `integration_test` package to run its tests in a web browser. +This package uses `package:integration_test` to run its tests in a web browser. -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. -## Running the tests - -Make sure you have updated to the latest Flutter master. - -1. Check what version of Chrome is running on the machine you're running tests on. - -2. Download and install driver for that version from here: - * - -3. Start the driver using `chromedriver --port=4444` - -4. Run tests: `flutter drive -d web-server --browser-name=chrome --driver=test_driver/integration_driver.dart --target=integration_test/TEST_NAME.dart`, or (in Linux): - - * Single: `./run_test.sh integration_test/TEST_NAME.dart` - * All: `./run_test.sh` +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. \ No newline at end of file diff --git a/packages/file_selector/file_selector_web/example/README.md b/packages/file_selector/file_selector_web/example/README.md index 6187e55841c9..8a6e74b107ea 100644 --- a/packages/file_selector/file_selector_web/example/README.md +++ b/packages/file_selector/file_selector_web/example/README.md @@ -1,21 +1,9 @@ # Testing -This package utilizes the `integration_test` package to run its tests in a web browser. +This package uses `package:integration_test` to run its tests in a web browser. -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. -## Running the tests - -Make sure you have updated to the latest Flutter master. - -1. Check what version of Chrome is running on the machine you're running tests on. - -2. Download and install driver for that version from here: - * - -3. Start the driver using `chromedriver --port=4444` - -4. Run tests: `flutter drive -d web-server --browser-name=chrome --driver=test_driver/integration_test.dart --target=integration_test/TEST_NAME.dart`, or (in Linux): - - * Single: `./run_test.sh integration_test/TEST_NAME.dart` - * All: `./run_test.sh` +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. \ No newline at end of file diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/README.md b/packages/google_maps_flutter/google_maps_flutter_web/example/README.md index 582288a561a4..3cdecfab2ab9 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/README.md @@ -1,31 +1,12 @@ # Testing -This package utilizes the `integration_test` package to run its tests in a web browser. +This package uses `package:integration_test` to run its tests in a web browser. -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. -## Running the tests +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. -Make sure you have updated to the latest Flutter master. - -1. Check what version of Chrome is running on the machine you're running tests on. - -2. Download and install driver for that version from here: - * - -3. Start the driver using `chromedriver --port=4444` - -4. Run tests: `flutter drive -d web-server --browser-name=chrome --driver=test_driver/integration_driver.dart --target=integration_test/TEST_NAME.dart`, or (in Linux): - - * Single: `./run_test.sh integration_test/TEST_NAME.dart` - * All: `./run_test.sh` - -## Mocks - -There's new `.mocks.dart` files next to the test files that use them. - -Mock files are [generated by `package:mockito`](https://github.com/dart-lang/mockito/blob/master/NULL_SAFETY_README.md#code-generation). The contents of these files can change with how the mocks are used within the tests, in addition to actual changes in the APIs they're mocking. - -Mock files can be updated either manually by running the following command: `flutter pub run build_runner build` (or the `regen_mocks.sh` script), or automatically on each call to the `run_test.sh` script. - -Please, add whatever changes show up in mock files to your PRs, or CI will fail. +See [Plugin Tests > Web Tests > Mocks](https://github.com/flutter/flutter/wiki/Plugin-Tests#mocks) +in the Flutter wiki for more information about the `.mocks.dart` files in this package. \ No newline at end of file diff --git a/packages/google_sign_in/google_sign_in_web/example/README.md b/packages/google_sign_in/google_sign_in_web/example/README.md index 0ec01e025570..8a6e74b107ea 100644 --- a/packages/google_sign_in/google_sign_in_web/example/README.md +++ b/packages/google_sign_in/google_sign_in_web/example/README.md @@ -1,21 +1,9 @@ # Testing -This package utilizes the `integration_test` package to run its tests in a web browser. +This package uses `package:integration_test` to run its tests in a web browser. -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. -## Running the tests - -Make sure you have updated to the latest Flutter master. - -1. Check what version of Chrome is running on the machine you're running tests on. - -2. Download and install driver for that version from here: - * - -3. Start the driver using `chromedriver --port=4444` - -4. Run tests: `flutter drive -d web-server --browser-name=chrome --driver=test_driver/integration_driver.dart --target=integration_test/TEST_NAME.dart`, or (in Linux): - - * Single: `./run_test.sh integration_test/TEST_NAME.dart` - * All: `./run_test.sh` +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. \ No newline at end of file diff --git a/packages/url_launcher/url_launcher_web/example/README.md b/packages/url_launcher/url_launcher_web/example/README.md index b75df09c33f1..3cdecfab2ab9 100644 --- a/packages/url_launcher/url_launcher_web/example/README.md +++ b/packages/url_launcher/url_launcher_web/example/README.md @@ -1,31 +1,12 @@ # Testing -This package utilizes the `integration_test` package to run its tests in a web browser. +This package uses `package:integration_test` to run its tests in a web browser. -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. -## Running the tests +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. -Make sure you have updated to the latest Flutter master. - -1. Check what version of Chrome is running on the machine you're running tests on. - -2. Download and install driver for that version from here: - * - -3. Start the driver using `chromedriver --port=4444` - -4. Run tests: `flutter drive -d web-server --browser-name=chrome --driver=test_driver/integration_test_driver.dart --target=integration_test/TEST_NAME.dart`, or (in Linux): - - * Single: `./run_test.sh integration_test/TEST_NAME.dart` - * All: `./run_test.sh` - -## Mocks - -There's `.mocks.dart` files next to the test files that use them. - -They're [generated by Mockito](https://github.com/dart-lang/mockito/blob/master/NULL_SAFETY_README.md#code-generation). - -Mocks might be manually re-generated with the following command: `flutter pub run build_runner build`. If there are any changes in the mocks, feel free to commit them. - -(Mocks will be auto-generated by the `run_test.sh` script as well.) +See [Plugin Tests > Web Tests > Mocks](https://github.com/flutter/flutter/wiki/Plugin-Tests#mocks) +in the Flutter wiki for more information about the `.mocks.dart` files in this package. \ No newline at end of file diff --git a/packages/video_player/video_player_web/example/README.md b/packages/video_player/video_player_web/example/README.md index 0ec01e025570..8a6e74b107ea 100644 --- a/packages/video_player/video_player_web/example/README.md +++ b/packages/video_player/video_player_web/example/README.md @@ -1,21 +1,9 @@ # Testing -This package utilizes the `integration_test` package to run its tests in a web browser. +This package uses `package:integration_test` to run its tests in a web browser. -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. -## Running the tests - -Make sure you have updated to the latest Flutter master. - -1. Check what version of Chrome is running on the machine you're running tests on. - -2. Download and install driver for that version from here: - * - -3. Start the driver using `chromedriver --port=4444` - -4. Run tests: `flutter drive -d web-server --browser-name=chrome --driver=test_driver/integration_driver.dart --target=integration_test/TEST_NAME.dart`, or (in Linux): - - * Single: `./run_test.sh integration_test/TEST_NAME.dart` - * All: `./run_test.sh` +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. \ No newline at end of file From 45a1ba6d91af70b8ad6d0c036f2ed3f60f72a357 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Mon, 19 Jul 2021 17:05:00 -0700 Subject: [PATCH 138/364] [google_sign_in] Add iOS unit tests (#4157) --- .../google_sign_in/CHANGELOG.md | 3 +- .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 4 +- .../ios/RunnerTests/GoogleSignInTests.m | 417 ++++++++++++++++-- .../ios/Classes/FLTGoogleSignInPlugin.m | 72 +-- .../Classes/FLTGoogleSignInPlugin.modulemap | 10 + .../ios/Classes/FLTGoogleSignInPlugin_Test.h | 17 + .../ios/Classes/google_sign_in-umbrella.h | 9 + .../google_sign_in/ios/google_sign_in.podspec | 3 +- .../google_sign_in/pubspec.yaml | 2 +- 10 files changed, 462 insertions(+), 77 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.modulemap create mode 100644 packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin_Test.h create mode 100644 packages/google_sign_in/google_sign_in/ios/Classes/google_sign_in-umbrella.h diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index cb4a65f42fa2..186a1d39a223 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 5.0.5 * Add iOS unit and UI integration test targets. +* Add iOS unit test module map. * Exclude arm64 simulators in example app. ## 5.0.4 diff --git a/packages/google_sign_in/google_sign_in/example/ios/Flutter/AppFrameworkInfo.plist b/packages/google_sign_in/google_sign_in/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/google_sign_in/google_sign_in/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj index 0c3cc430d23e..06857ed2bd59 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_sign_in/google_sign_in/example/ios/Runner.xcodeproj/project.pbxproj @@ -539,7 +539,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -589,7 +589,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m index adbf61326c8d..6f8b821a5299 100644 --- a/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m +++ b/packages/google_sign_in/google_sign_in/example/ios/RunnerTests/GoogleSignInTests.m @@ -6,6 +6,7 @@ @import XCTest; @import google_sign_in; +@import google_sign_in.Test; @import GoogleSignIn; // OCMock library doesn't generate a valid modulemap. @@ -16,7 +17,7 @@ @interface FLTGoogleSignInPluginTest : XCTestCase @property(strong, nonatomic) NSObject *mockBinaryMessenger; @property(strong, nonatomic) NSObject *mockPluginRegistrar; @property(strong, nonatomic) FLTGoogleSignInPlugin *plugin; -@property(strong, nonatomic) GIDSignIn *mockSharedInstance; +@property(strong, nonatomic) id mockSignIn; @end @@ -26,39 +27,377 @@ - (void)setUp { [super setUp]; self.mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); self.mockPluginRegistrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); - self.mockSharedInstance = [OCMockObject partialMockForObject:[GIDSignIn sharedInstance]]; + + id mockSignIn = OCMClassMock([GIDSignIn class]); + self.mockSignIn = mockSignIn; + OCMStub(self.mockPluginRegistrar.messenger).andReturn(self.mockBinaryMessenger); - self.plugin = [[FLTGoogleSignInPlugin alloc] init]; + self.plugin = [[FLTGoogleSignInPlugin alloc] initWithSignIn:mockSignIn]; [FLTGoogleSignInPlugin registerWithRegistrar:self.mockPluginRegistrar]; } -- (void)tearDown { - [((OCMockObject *)self.mockSharedInstance) stopMocking]; - [super tearDown]; +- (void)testUnimplementedMethod { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"bogus" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(id result) { + XCTAssertEqualObjects(result, FlutterMethodNotImplemented); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testSignOut { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"signOut" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(id result) { + XCTAssertNil(result); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerify([self.mockSignIn signOut]); +} + +- (void)testDisconnect { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"disconnect" + arguments:nil]; + + [self.plugin handleMethodCall:methodCall + result:^(id result){ + }]; + OCMVerify([self.mockSignIn disconnect]); +} + +- (void)testClearAuthCache { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"clearAuthCache" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(id result) { + XCTAssertNil(result); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +#pragma mark - Init + +- (void)testInitGamesSignInUnsupported { + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"init" + arguments:@{@"signInOption" : @"SignInOption.games"}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"unsupported-options"); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testInitGoogleServiceInfoPlist { + FlutterMethodCall *methodCall = [FlutterMethodCall + methodCallWithMethodName:@"init" + arguments:@{@"scopes" : @[ @"mockScope1" ], @"hostedDomain" : @"example.com"}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(id result) { + XCTAssertNil(result); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + + id mockSignIn = self.mockSignIn; + OCMVerify([mockSignIn setScopes:@[ @"mockScope1" ]]); + OCMVerify([mockSignIn setHostedDomain:@"example.com"]); + + // Set in example app GoogleService-Info.plist. + OCMVerify([mockSignIn + setClientID:@"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"]); + OCMVerify([mockSignIn setServerClientID:@"YOUR_SERVER_CLIENT_ID"]); +} + +- (void)testInitNullDomain { + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"init" + arguments:@{@"hostedDomain" : [NSNull null]}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(id r) { + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerify([self.mockSignIn setHostedDomain:nil]); +} + +- (void)testInitDynamicClientId { + FlutterMethodCall *methodCall = + [FlutterMethodCall methodCallWithMethodName:@"init" + arguments:@{@"clientId" : @"mockClientId"}]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(id r) { + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMVerify([self.mockSignIn setClientID:@"mockClientId"]); +} + +#pragma mark - Is signed in + +- (void)testIsNotSignedIn { + OCMStub([self.mockSignIn hasPreviousSignIn]).andReturn(NO); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"isSignedIn" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(NSNumber *result) { + XCTAssertFalse(result.boolValue); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testIsSignedIn { + OCMStub([self.mockSignIn hasPreviousSignIn]).andReturn(YES); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"isSignedIn" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(NSNumber *result) { + XCTAssertTrue(result.boolValue); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +#pragma mark - Sign in silently + +- (void)testSignInSilently { + OCMExpect([self.mockSignIn restorePreviousSignIn]); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"signInSilently" + arguments:nil]; + + [self.plugin handleMethodCall:methodCall + result:^(id result){ + }]; + OCMVerifyAll(self.mockSignIn); +} + +- (void)testSignInSilentlyFailsConcurrently { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"signInSilently" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + + OCMExpect([self.mockSignIn restorePreviousSignIn]).andDo(^(NSInvocation *invocation) { + // Simulate calling the same method while the previous one is in flight. + [self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"concurrent-requests"); + [expectation fulfill]; + }]; + }); + + [self.plugin handleMethodCall:methodCall + result:^(id result){ + }]; + + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +#pragma mark - Sign in + +- (void)testSignIn { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"signIn" + arguments:nil]; + + [self.plugin handleMethodCall:methodCall + result:^(NSNumber *result){ + }]; + + id mockSignIn = self.mockSignIn; + OCMVerify([mockSignIn + setPresentingViewController:[OCMArg isKindOfClass:[FlutterViewController class]]]); + OCMVerify([mockSignIn signIn]); +} + +- (void)testSignInExecption { + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"signIn" + arguments:nil]; + OCMExpect([self.mockSignIn signIn]) + .andThrow([NSException exceptionWithName:@"MockName" reason:@"MockReason" userInfo:nil]); + + __block FlutterError *error; + XCTAssertThrows([self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + error = result; + }]); + + XCTAssertEqualObjects(error.code, @"google_sign_in"); + XCTAssertEqualObjects(error.message, @"MockReason"); + XCTAssertEqualObjects(error.details, @"MockName"); +} + +#pragma mark - Get tokens + +- (void)testGetTokens { + id mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + + id mockAuthentication = OCMClassMock([GIDAuthentication class]); + OCMStub([mockAuthentication idToken]).andReturn(@"mockIdToken"); + OCMStub([mockAuthentication accessToken]).andReturn(@"mockAccessToken"); + [[mockAuthentication stub] + getTokensWithHandler:[OCMArg invokeBlockWithArgs:mockAuthentication, [NSNull null], nil]]; + OCMStub([mockUser authentication]).andReturn(mockAuthentication); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"getTokens" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(NSDictionary *result) { + XCTAssertEqualObjects(result[@"idToken"], @"mockIdToken"); + XCTAssertEqualObjects(result[@"accessToken"], @"mockAccessToken"); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } +- (void)testGetTokensNoAuthKeychainError { + id mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + + id mockAuthentication = OCMClassMock([GIDAuthentication class]); + NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeHasNoAuthInKeychain + userInfo:nil]; + [[mockAuthentication stub] + getTokensWithHandler:[OCMArg invokeBlockWithArgs:[NSNull null], error, nil]]; + OCMStub([mockUser authentication]).andReturn(mockAuthentication); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"getTokens" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"sign_in_required"); + XCTAssertEqualObjects(result.message, kGIDSignInErrorDomain); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testGetTokensCancelledError { + id mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + + id mockAuthentication = OCMClassMock([GIDAuthentication class]); + NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeCanceled + userInfo:nil]; + [[mockAuthentication stub] + getTokensWithHandler:[OCMArg invokeBlockWithArgs:[NSNull null], error, nil]]; + OCMStub([mockUser authentication]).andReturn(mockAuthentication); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"getTokens" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"sign_in_canceled"); + XCTAssertEqualObjects(result.message, kGIDSignInErrorDomain); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testGetTokensURLError { + id mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + + id mockAuthentication = OCMClassMock([GIDAuthentication class]); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:nil]; + [[mockAuthentication stub] + getTokensWithHandler:[OCMArg invokeBlockWithArgs:[NSNull null], error, nil]]; + OCMStub([mockUser authentication]).andReturn(mockAuthentication); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"getTokens" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"network_error"); + XCTAssertEqualObjects(result.message, NSURLErrorDomain); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testGetTokensUnknownError { + id mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + + id mockAuthentication = OCMClassMock([GIDAuthentication class]); + NSError *error = [NSError errorWithDomain:@"BogusDomain" code:42 userInfo:nil]; + [[mockAuthentication stub] + getTokensWithHandler:[OCMArg invokeBlockWithArgs:[NSNull null], error, nil]]; + OCMStub([mockUser authentication]).andReturn(mockAuthentication); + + FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"getTokens" + arguments:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; + [self.plugin handleMethodCall:methodCall + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"sign_in_failed"); + XCTAssertEqualObjects(result.message, @"BogusDomain"); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +#pragma mark - Request scopes + - (void)testRequestScopesResultErrorIfNotSignedIn { - OCMStub(self.mockSharedInstance.currentUser).andReturn(nil); + OCMStub([self.mockSignIn currentUser]).andReturn(nil); FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"requestScopes" arguments:@{@"scopes" : @[ @"mockScope1" ]}]; XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - __block id result; [self.plugin handleMethodCall:methodCall - result:^(id r) { + result:^(FlutterError *result) { + XCTAssertEqualObjects(result.code, @"sign_in_required"); [expectation fulfill]; - result = r; }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertEqualObjects([((FlutterError *)result) code], @"sign_in_required"); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesIfNoMissingScope { // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + GIDGoogleUser *mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); NSArray *requestedScopes = @[ @"mockScope1" ]; OCMStub(mockUser.grantedScopes).andReturn(requestedScopes); FlutterMethodCall *methodCall = @@ -66,22 +405,22 @@ - (void)testRequestScopesIfNoMissingScope { arguments:@{@"scopes" : requestedScopes}]; XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - __block id result; [self.plugin handleMethodCall:methodCall - result:^(id r) { + result:^(NSNumber *result) { + XCTAssertTrue(result.boolValue); [expectation fulfill]; - result = r; }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertTrue([result boolValue]); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesRequestsIfNotGranted { // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + GIDGoogleUser *mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); NSArray *requestedScopes = @[ @"mockScope1" ]; OCMStub(mockUser.grantedScopes).andReturn(@[]); + id mockSignIn = self.mockSignIn; + OCMStub([mockSignIn scopes]).andReturn(@[]); FlutterMethodCall *methodCall = [FlutterMethodCall methodCallWithMethodName:@"requestScopes" @@ -91,19 +430,19 @@ - (void)testRequestScopesRequestsIfNotGranted { result:^(id r){ }]; - XCTAssertTrue([self.mockSharedInstance.scopes containsObject:@"mockScope1"]); - OCMVerify([self.mockSharedInstance signIn]); + OCMVerify([mockSignIn setScopes:@[ @"mockScope1" ]]); + OCMVerify([mockSignIn signIn]); } - (void)testRequestScopesReturnsFalseIfNotGranted { // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + GIDGoogleUser *mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); NSArray *requestedScopes = @[ @"mockScope1" ]; OCMStub(mockUser.grantedScopes).andReturn(@[]); - OCMStub([self.mockSharedInstance signIn]).andDo(^(NSInvocation *invocation) { - [((NSObject *)self.plugin) signIn:self.mockSharedInstance + OCMStub([self.mockSignIn signIn]).andDo(^(NSInvocation *invocation) { + [((NSObject *)self.plugin) signIn:self.mockSignIn didSignInForUser:mockUser withError:nil]; }); @@ -113,27 +452,25 @@ - (void)testRequestScopesReturnsFalseIfNotGranted { arguments:@{@"scopes" : requestedScopes}]; XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns false"]; - __block id result; [self.plugin handleMethodCall:methodCall - result:^(id r) { + result:^(NSNumber *result) { + XCTAssertFalse(result.boolValue); [expectation fulfill]; - result = r; }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertFalse([result boolValue]); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesReturnsTrueIfGranted { // Mock Google Signin internal calls - GIDGoogleUser *mockUser = OCMClassMock(GIDGoogleUser.class); - OCMStub(self.mockSharedInstance.currentUser).andReturn(mockUser); + GIDGoogleUser *mockUser = OCMClassMock([GIDGoogleUser class]); + OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); NSArray *requestedScopes = @[ @"mockScope1" ]; NSMutableArray *availableScopes = [NSMutableArray new]; OCMStub(mockUser.grantedScopes).andReturn(availableScopes); - OCMStub([self.mockSharedInstance signIn]).andDo(^(NSInvocation *invocation) { + OCMStub([self.mockSignIn signIn]).andDo(^(NSInvocation *invocation) { [availableScopes addObject:@"mockScope1"]; - [((NSObject *)self.plugin) signIn:self.mockSharedInstance + [((NSObject *)self.plugin) signIn:self.mockSignIn didSignInForUser:mockUser withError:nil]; }); @@ -143,14 +480,12 @@ - (void)testRequestScopesReturnsTrueIfGranted { arguments:@{@"scopes" : requestedScopes}]; XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - __block id result; [self.plugin handleMethodCall:methodCall - result:^(id r) { + result:^(NSNumber *result) { + XCTAssertTrue(result.boolValue); [expectation fulfill]; - result = r; }]; - [self waitForExpectations:@[ expectation ] timeout:5]; - XCTAssertTrue([result boolValue]); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } @end diff --git a/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.m b/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.m index 578f64d5a41c..d13d64d2ba04 100644 --- a/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.m +++ b/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.m @@ -3,6 +3,8 @@ // found in the LICENSE file. #import "FLTGoogleSignInPlugin.h" +#import "FLTGoogleSignInPlugin_Test.h" + #import // The key within `GoogleService-Info.plist` used to hold the application's @@ -35,11 +37,15 @@ } @interface FLTGoogleSignInPlugin () +@property(strong, readonly) GIDSignIn *signIn; + +// Redeclared as not a designated initializer. +- (instancetype)init; @end @implementation FLTGoogleSignInPlugin { FlutterResult _accountRequest; - NSArray *_additionalScopesRequest; + NSArray *_additionalScopesRequest; } + (void)registerWithRegistrar:(NSObject *)registrar { @@ -52,9 +58,14 @@ + (void)registerWithRegistrar:(NSObject *)registrar { } - (instancetype)init { + return [self initWithSignIn:GIDSignIn.sharedInstance]; +} + +- (instancetype)initWithSignIn:(GIDSignIn *)signIn { self = [super init]; if (self) { - [GIDSignIn sharedInstance].delegate = self; + _signIn = signIn; + _signIn.delegate = self; // On the iOS simulator, we get "Broken pipe" errors after sign-in for some // unknown reason. We can avoid crashing the app by ignoring them. @@ -76,22 +87,22 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result NSString *path = [[NSBundle mainBundle] pathForResource:@"GoogleService-Info" ofType:@"plist"]; if (path) { - NSMutableDictionary *plist = [[NSMutableDictionary alloc] initWithContentsOfFile:path]; - BOOL hasDynamicClientId = - [[call.arguments valueForKey:@"clientId"] isKindOfClass:[NSString class]]; + NSMutableDictionary *plist = + [[NSMutableDictionary alloc] initWithContentsOfFile:path]; + BOOL hasDynamicClientId = [call.arguments[@"clientId"] isKindOfClass:[NSString class]]; if (hasDynamicClientId) { - [GIDSignIn sharedInstance].clientID = [call.arguments valueForKey:@"clientId"]; + self.signIn.clientID = call.arguments[@"clientId"]; } else { - [GIDSignIn sharedInstance].clientID = plist[kClientIdKey]; + self.signIn.clientID = plist[kClientIdKey]; } - [GIDSignIn sharedInstance].serverClientID = plist[kServerClientIdKey]; - [GIDSignIn sharedInstance].scopes = call.arguments[@"scopes"]; + self.signIn.serverClientID = plist[kServerClientIdKey]; + self.signIn.scopes = call.arguments[@"scopes"]; if (call.arguments[@"hostedDomain"] == [NSNull null]) { - [GIDSignIn sharedInstance].hostedDomain = nil; + self.signIn.hostedDomain = nil; } else { - [GIDSignIn sharedInstance].hostedDomain = call.arguments[@"hostedDomain"]; + self.signIn.hostedDomain = call.arguments[@"hostedDomain"]; } result(nil); } else { @@ -102,23 +113,23 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } } else if ([call.method isEqualToString:@"signInSilently"]) { if ([self setAccountRequest:result]) { - [[GIDSignIn sharedInstance] restorePreviousSignIn]; + [self.signIn restorePreviousSignIn]; } } else if ([call.method isEqualToString:@"isSignedIn"]) { - result(@([[GIDSignIn sharedInstance] hasPreviousSignIn])); + result(@([self.signIn hasPreviousSignIn])); } else if ([call.method isEqualToString:@"signIn"]) { - [GIDSignIn sharedInstance].presentingViewController = [self topViewController]; + self.signIn.presentingViewController = [self topViewController]; if ([self setAccountRequest:result]) { @try { - [[GIDSignIn sharedInstance] signIn]; + [self.signIn signIn]; } @catch (NSException *e) { result([FlutterError errorWithCode:@"google_sign_in" message:e.reason details:e.name]); [e raise]; } } } else if ([call.method isEqualToString:@"getTokens"]) { - GIDGoogleUser *currentUser = [GIDSignIn sharedInstance].currentUser; + GIDGoogleUser *currentUser = self.signIn.currentUser; GIDAuthentication *auth = currentUser.authentication; [auth getTokensWithHandler:^void(GIDAuthentication *authentication, NSError *error) { result(error != nil ? getFlutterError(error) : @{ @@ -127,18 +138,18 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result }); }]; } else if ([call.method isEqualToString:@"signOut"]) { - [[GIDSignIn sharedInstance] signOut]; + [self.signIn signOut]; result(nil); } else if ([call.method isEqualToString:@"disconnect"]) { if ([self setAccountRequest:result]) { - [[GIDSignIn sharedInstance] disconnect]; + [self.signIn disconnect]; } } else if ([call.method isEqualToString:@"clearAuthCache"]) { // There's nothing to be done here on iOS since the expired/invalid // tokens are refreshed automatically by getTokensWithHandler. result(nil); } else if ([call.method isEqualToString:@"requestScopes"]) { - GIDGoogleUser *user = [GIDSignIn sharedInstance].currentUser; + GIDGoogleUser *user = self.signIn.currentUser; if (user == nil) { result([FlutterError errorWithCode:@"sign_in_required" message:@"No account to grant scopes." @@ -146,9 +157,9 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result return; } - NSArray *currentScopes = [GIDSignIn sharedInstance].scopes; - NSArray *scopes = call.arguments[@"scopes"]; - NSArray *missingScopes = [scopes + NSArray *currentScopes = self.signIn.scopes; + NSArray *scopes = call.arguments[@"scopes"]; + NSArray *missingScopes = [scopes filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id scope, NSDictionary *bindings) { return ![user.grantedScopes containsObject:scope]; @@ -161,12 +172,11 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result if ([self setAccountRequest:result]) { _additionalScopesRequest = missingScopes; - [GIDSignIn sharedInstance].scopes = - [currentScopes arrayByAddingObjectsFromArray:missingScopes]; - [GIDSignIn sharedInstance].presentingViewController = [self topViewController]; - [GIDSignIn sharedInstance].loginHint = user.profile.email; + self.signIn.scopes = [currentScopes arrayByAddingObjectsFromArray:missingScopes]; + self.signIn.presentingViewController = [self topViewController]; + self.signIn.loginHint = user.profile.email; @try { - [[GIDSignIn sharedInstance] signIn]; + [self.signIn signIn]; } @catch (NSException *e) { result([FlutterError errorWithCode:@"request_scopes" message:e.reason details:e.name]); } @@ -187,8 +197,10 @@ - (BOOL)setAccountRequest:(FlutterResult)request { return YES; } -- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options { - return [[GIDSignIn sharedInstance] handleURL:url]; +- (BOOL)application:(UIApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options { + return [self.signIn handleURL:url]; } #pragma mark - protocol @@ -251,7 +263,7 @@ - (void)signIn:(GIDSignIn *)signIn #pragma mark - private methods -- (void)respondWithAccount:(id)account error:(NSError *)error { +- (void)respondWithAccount:(NSDictionary *)account error:(NSError *)error { FlutterResult result = _accountRequest; _accountRequest = nil; result(error != nil ? getFlutterError(error) : account); diff --git a/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.modulemap b/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.modulemap new file mode 100644 index 000000000000..271f509e7fd7 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin.modulemap @@ -0,0 +1,10 @@ +framework module google_sign_in { + umbrella header "google_sign_in-umbrella.h" + + export * + module * { export * } + + explicit module Test { + header "FLTGoogleSignInPlugin_Test.h" + } +} diff --git a/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin_Test.h b/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin_Test.h new file mode 100644 index 000000000000..8fa6cf348018 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/ios/Classes/FLTGoogleSignInPlugin_Test.h @@ -0,0 +1,17 @@ +// Copyright 2013 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. + +// This header is available in the Test module. Import via "@import google_sign_in.Test;" + +#import + +@class GIDSignIn; + +/// Methods exposed for unit testing. +@interface FLTGoogleSignInPlugin () + +/// Inject @c GIDSignIn for testing. +- (instancetype)initWithSignIn:(GIDSignIn *)signIn NS_DESIGNATED_INITIALIZER; + +@end diff --git a/packages/google_sign_in/google_sign_in/ios/Classes/google_sign_in-umbrella.h b/packages/google_sign_in/google_sign_in/ios/Classes/google_sign_in-umbrella.h new file mode 100644 index 000000000000..343c390f1782 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/ios/Classes/google_sign_in-umbrella.h @@ -0,0 +1,9 @@ +// Copyright 2013 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 +#import + +FOUNDATION_EXPORT double google_sign_inVersionNumber; +FOUNDATION_EXPORT const unsigned char google_sign_inVersionString[]; diff --git a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec index bf0b75f2957d..6b0741c65122 100644 --- a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec +++ b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec @@ -12,8 +12,9 @@ Enables Google Sign-In in Flutter apps. s.license = { :type => 'BSD', :file => '../LICENSE' } s.author = { 'Flutter Team' => 'flutter-dev@googlegroups.com' } s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/google_sign_in' } - s.source_files = 'Classes/**/*' + s.source_files = 'Classes/**/*.{h,m}' s.public_header_files = 'Classes/**/*.h' + s.module_map = 'Classes/FLTGoogleSignInPlugin.modulemap' s.dependency 'Flutter' s.dependency 'GoogleSignIn', '~> 5.0' s.static_framework = true diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index a57f2197576d..14f7d8901301 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.0.4 +version: 5.0.5 environment: sdk: ">=2.12.0 <3.0.0" From 036fb33ae18bd6a1d2af29302f2124132ca10768 Mon Sep 17 00:00:00 2001 From: Balvinder Singh Gambhir Date: Tue, 20 Jul 2021 06:36:03 +0530 Subject: [PATCH 139/364] [image_picker_for_web] Support multiple pick. Store name and other local file properties in XFile. (#4166) --- .../image_picker/image_picker_for_web/AUTHORS | 1 + .../image_picker_for_web/CHANGELOG.md | 6 + .../image_picker_for_web_test.dart | 81 +++++++++++-- .../lib/image_picker_for_web.dart | 113 ++++++++++++------ .../image_picker_for_web/pubspec.yaml | 2 +- 5 files changed, 159 insertions(+), 44 deletions(-) diff --git a/packages/image_picker/image_picker_for_web/AUTHORS b/packages/image_picker/image_picker_for_web/AUTHORS index 493a0b4ef9c2..d6ad42a677e5 100644 --- a/packages/image_picker/image_picker_for_web/AUTHORS +++ b/packages/image_picker/image_picker_for_web/AUTHORS @@ -64,3 +64,4 @@ Aleksandr Yurkovskiy Anton Borries Alex Li Rahul Raj <64.rahulraj@gmail.com> +Balvinder Singh Gambhir diff --git a/packages/image_picker/image_picker_for_web/CHANGELOG.md b/packages/image_picker/image_picker_for_web/CHANGELOG.md index b0379ad2c07c..f32a5d8e92cd 100644 --- a/packages/image_picker/image_picker_for_web/CHANGELOG.md +++ b/packages/image_picker/image_picker_for_web/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2.1.1 + +* Implemented `getMultiImage`. +* Initialized the following `XFile` attributes for picked files: + * `name`, `length`, `mimeType` and `lastModified`. + # 2.1.0 * Implemented `getImage`, `getVideo` and `getFile` methods that return `XFile` instances. diff --git a/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart index c6d0b3b532ca..c1025a9f07d3 100644 --- a/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart +++ b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart @@ -11,9 +11,16 @@ import 'package:image_picker_for_web/image_picker_for_web.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:integration_test/integration_test.dart'; -final String expectedStringContents = "Hello, world!"; +final String expectedStringContents = 'Hello, world!'; +final String otherStringContents = 'Hello again, world!'; final Uint8List bytes = utf8.encode(expectedStringContents) as Uint8List; -final html.File textFile = html.File([bytes], "hello.txt"); +final Uint8List otherBytes = utf8.encode(otherStringContents) as Uint8List; +final Map options = { + 'type': 'text/plain', + 'lastModified': DateTime.utc(2017, 12, 13).millisecondsSinceEpoch, +}; +final html.File textFile = html.File([bytes], 'hello.txt', options); +final html.File secondTextFile = html.File([otherBytes], 'secondFile.txt'); void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -30,7 +37,7 @@ void main() { final overrides = ImagePickerPluginTestOverrides() ..createInputElement = ((_, __) => mockInput) - ..getFileFromInput = ((_) => textFile); + ..getMultipleFilesFromInput = ((_) => [textFile]); final plugin = ImagePickerPlugin(overrides: overrides); @@ -51,20 +58,58 @@ void main() { final overrides = ImagePickerPluginTestOverrides() ..createInputElement = ((_, __) => mockInput) - ..getFileFromInput = ((_) => textFile); + ..getMultipleFilesFromInput = ((_) => [textFile]); final plugin = ImagePickerPlugin(overrides: overrides); // Init the pick file dialog... - final file = plugin.getFile(); + final image = plugin.getImage(source: ImageSource.camera); // Mock the browser behavior of selecting a file... mockInput.dispatchEvent(html.Event('change')); // Now the file should be available - expect(file, completes); + expect(image, completes); + // And readable - expect((await file).readAsBytes(), completion(isNotEmpty)); + final XFile file = await image; + expect(file.readAsBytes(), completion(isNotEmpty)); + expect(file.name, textFile.name); + expect(file.length(), completion(textFile.size)); + expect(file.mimeType, textFile.type); + expect( + file.lastModified(), + completion( + DateTime.fromMillisecondsSinceEpoch(textFile.lastModified!), + )); + }); + + testWidgets('Can select multiple files', (WidgetTester tester) async { + final mockInput = html.FileUploadInputElement(); + + final overrides = ImagePickerPluginTestOverrides() + ..createInputElement = ((_, __) => mockInput) + ..getMultipleFilesFromInput = ((_) => [textFile, secondTextFile]); + + final plugin = ImagePickerPlugin(overrides: overrides); + + // Init the pick file dialog... + final files = plugin.getMultiImage(); + + // Mock the browser behavior of selecting a file... + mockInput.dispatchEvent(html.Event('change')); + + // Now the file should be available + expect(files, completes); + + // And readable + expect((await files).first.readAsBytes(), completion(isNotEmpty)); + + // Peek into the second file... + final XFile secondFile = (await files).elementAt(1); + expect(secondFile.readAsBytes(), completion(isNotEmpty)); + expect(secondFile.name, secondTextFile.name); + expect(secondFile.length(), completion(secondTextFile.size)); }); // There's no good way of detecting when the user has "aborted" the selection. @@ -94,6 +139,7 @@ void main() { expect(input.attributes, containsPair('accept', 'any')); expect(input.attributes, isNot(contains('capture'))); + expect(input.attributes, isNot(contains('multiple'))); }); testWidgets('accept: any, capture: something', (WidgetTester tester) async { @@ -101,6 +147,27 @@ void main() { expect(input.attributes, containsPair('accept', 'any')); expect(input.attributes, containsPair('capture', 'something')); + expect(input.attributes, isNot(contains('multiple'))); + }); + + testWidgets('accept: any, capture: null, multi: true', + (WidgetTester tester) async { + html.Element input = + plugin.createInputElement('any', null, multiple: true); + + expect(input.attributes, containsPair('accept', 'any')); + expect(input.attributes, isNot(contains('capture'))); + expect(input.attributes, contains('multiple')); + }); + + testWidgets('accept: any, capture: something, multi: true', + (WidgetTester tester) async { + html.Element input = + plugin.createInputElement('any', 'something', multiple: true); + + expect(input.attributes, containsPair('accept', 'any')); + expect(input.attributes, containsPair('capture', 'something')); + expect(input.attributes, contains('multiple')); }); }); } diff --git a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart index 08ce801cafbe..b170ee3256ab 100644 --- a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart +++ b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart @@ -18,6 +18,7 @@ final String _kAcceptVideoMimeType = 'video/3gpp,video/x-m4v,video/mp4,video/*'; /// This class implements the `package:image_picker` functionality for the web. class ImagePickerPlugin extends ImagePickerPlatform { final ImagePickerPluginTestOverrides? _overrides; + bool get _hasOverrides => _overrides != null; late html.Element _target; @@ -115,9 +116,13 @@ class ImagePickerPlugin extends ImagePickerPlatform { double? maxHeight, int? imageQuality, CameraDevice preferredCameraDevice = CameraDevice.rear, - }) { + }) async { String? capture = computeCaptureAttribute(source, preferredCameraDevice); - return getFile(accept: _kAcceptImageMimeType, capture: capture); + List files = await getFiles( + accept: _kAcceptImageMimeType, + capture: capture, + ); + return files.first; } /// Returns an [XFile] containing the video that was picked. @@ -137,25 +142,48 @@ class ImagePickerPlugin extends ImagePickerPlatform { required ImageSource source, CameraDevice preferredCameraDevice = CameraDevice.rear, Duration? maxDuration, - }) { + }) async { String? capture = computeCaptureAttribute(source, preferredCameraDevice); - return getFile(accept: _kAcceptVideoMimeType, capture: capture); + List files = await getFiles( + accept: _kAcceptVideoMimeType, + capture: capture, + ); + return files.first; + } + + /// Injects a file input, and returns a list of XFile that the user selected locally. + @override + Future> getMultiImage({ + double? maxWidth, + double? maxHeight, + int? imageQuality, + }) { + return getFiles(accept: _kAcceptImageMimeType, multiple: true); } /// Injects a file input with the specified accept+capture attributes, and - /// returns the PickedFile that the user selected locally. + /// returns a list of XFile that the user selected locally. /// /// `capture` is only supported in mobile browsers. + /// + /// `multiple` can be passed to allow for multiple selection of files. Defaults + /// to false. + /// /// See https://caniuse.com/#feat=html-media-capture @visibleForTesting - Future getFile({ + Future> getFiles({ String? accept, String? capture, + bool multiple = false, }) { - html.FileUploadInputElement input = - createInputElement(accept, capture) as html.FileUploadInputElement; + html.FileUploadInputElement input = createInputElement( + accept, + capture, + multiple: multiple, + ) as html.FileUploadInputElement; _injectAndActivate(input); - return _getSelectedXFile(input); + + return _getSelectedXFiles(input); } // DOM methods @@ -171,24 +199,19 @@ class ImagePickerPlugin extends ImagePickerPlatform { return null; } - html.File? _getFileFromInput(html.FileUploadInputElement input) { + List? _getFilesFromInput(html.FileUploadInputElement input) { if (_hasOverrides) { - return _overrides!.getFileFromInput(input); + return _overrides!.getMultipleFilesFromInput(input); } - return input.files?.first; + return input.files; } /// Handles the OnChange event from a FileUploadInputElement object - /// Returns the objectURL of the selected file. - String? _handleOnChangeEvent(html.Event event) { + /// Returns a list of selected files. + List? _handleOnChangeEvent(html.Event event) { final html.FileUploadInputElement input = event.target as html.FileUploadInputElement; - final html.File? file = _getFileFromInput(input); - - if (file != null) { - return html.Url.createObjectUrl(file); - } - return null; + return _getFilesFromInput(input); } /// Monitors an and returns the selected file. @@ -196,9 +219,11 @@ class ImagePickerPlugin extends ImagePickerPlatform { final Completer _completer = Completer(); // Observe the input until we can return something input.onChange.first.then((event) { - final objectUrl = _handleOnChangeEvent(event); - if (!_completer.isCompleted && objectUrl != null) { - _completer.complete(PickedFile(objectUrl)); + final files = _handleOnChangeEvent(event); + if (!_completer.isCompleted && files != null) { + _completer.complete(PickedFile( + html.Url.createObjectUrl(files.first), + )); } }); input.onError.first.then((event) { @@ -212,13 +237,24 @@ class ImagePickerPlugin extends ImagePickerPlatform { return _completer.future; } - Future _getSelectedXFile(html.FileUploadInputElement input) { - final Completer _completer = Completer(); + /// Monitors an and returns the selected file(s). + Future> _getSelectedXFiles(html.FileUploadInputElement input) { + final Completer> _completer = Completer>(); // Observe the input until we can return something input.onChange.first.then((event) { - final objectUrl = _handleOnChangeEvent(event); - if (!_completer.isCompleted && objectUrl != null) { - _completer.complete(XFile(objectUrl)); + final files = _handleOnChangeEvent(event); + if (!_completer.isCompleted && files != null) { + _completer.complete(files + .map((file) => XFile( + html.Url.createObjectUrl(file), + name: file.name, + length: file.size, + lastModified: DateTime.fromMillisecondsSinceEpoch( + file.lastModified ?? DateTime.now().millisecondsSinceEpoch, + ), + mimeType: file.type, + )) + .toList()); } }); input.onError.first.then((event) { @@ -248,12 +284,18 @@ class ImagePickerPlugin extends ImagePickerPlatform { /// Creates an input element that accepts certain file types, and /// allows to `capture` from the device's cameras (where supported) @visibleForTesting - html.Element createInputElement(String? accept, String? capture) { + html.Element createInputElement( + String? accept, + String? capture, { + bool multiple = false, + }) { if (_hasOverrides) { return _overrides!.createInputElement(accept, capture); } - html.Element element = html.FileUploadInputElement()..accept = accept; + html.Element element = html.FileUploadInputElement() + ..accept = accept + ..multiple = multiple; if (capture != null) { element.setAttribute('capture', capture); @@ -278,11 +320,10 @@ typedef OverrideCreateInputFunction = html.Element Function( String? capture, ); -/// A function that extracts a [html.File] from the file `input` passed in. +/// A function that extracts list of files from the file `input` passed in. @visibleForTesting -typedef OverrideExtractFilesFromInputFunction = html.File Function( - html.Element? input, -); +typedef OverrideExtractMultipleFilesFromInputFunction = List + Function(html.Element? input); /// Overrides for some of the functionality above. @visibleForTesting @@ -290,6 +331,6 @@ class ImagePickerPluginTestOverrides { /// Override the creation of the input element. late OverrideCreateInputFunction createInputElement; - /// Override the extraction of the selected file from an input element. - late OverrideExtractFilesFromInputFunction getFileFromInput; + /// Override the extraction of the selected files from an input element. + late OverrideExtractMultipleFilesFromInputFunction getMultipleFilesFromInput; } diff --git a/packages/image_picker/image_picker_for_web/pubspec.yaml b/packages/image_picker/image_picker_for_web/pubspec.yaml index d9b9c5e5cb86..b2479285a3ea 100644 --- a/packages/image_picker/image_picker_for_web/pubspec.yaml +++ b/packages/image_picker/image_picker_for_web/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_for_web description: Web platform implementation of image_picker repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker_for_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 2.1.0 +version: 2.1.1 environment: sdk: ">=2.12.0 <3.0.0" From a807b5c7b1f70e6e75833b50deb384a2b736ac14 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 20 Jul 2021 10:01:07 -0700 Subject: [PATCH 140/364] [flutter_plugin_tools] Use -version with java (#4171) --- script/tool/CHANGELOG.md | 4 +++- script/tool/lib/src/format_command.dart | 5 ++++- script/tool/pubspec.yaml | 2 +- script/tool/test/format_command_test.dart | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 17b28927538d..1e447721d13f 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,6 +1,8 @@ -## NEXT +## 0.4.1 - Improved `license-check` output. +- Use `java -version` rather than `java --version`, for compatibility with more + versions of Java. ## 0.4.0 diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index c67fb96d2835..d09a94b1aefe 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -244,9 +244,12 @@ class FormatCommand extends PluginCommand { /// Returns true if [command] can be run successfully. Future _hasDependency(String command) async { + // Some versions of Java accept both -version and --version, but some only + // accept -version. + final String versionFlag = command == 'java' ? '-version' : '--version'; try { final io.ProcessResult result = - await processRunner.run(command, ['--version']); + await processRunner.run(command, [versionFlag]); if (result.exitCode != 0) { return false; } diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 7dadc598d4b4..7b2cdd4f4101 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.4.0 +version: 0.4.1 dependencies: args: ^2.1.0 diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index 4728c3136556..b072e5d30aaf 100644 --- a/script/tool/test/format_command_test.dart +++ b/script/tool/test/format_command_test.dart @@ -146,7 +146,7 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - const ProcessCall('java', ['--version'], null), + const ProcessCall('java', ['-version'], null), ProcessCall( 'java', [ From 0bbef40ceeedca87882c27b0c33215d92f737ee2 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 20 Jul 2021 19:31:05 +0200 Subject: [PATCH 141/364] [webview_flutter] Move webview_flutter to webview_flutter/webview_flutter (#4152) --- .../webview_flutter/{ => webview_flutter}/AUTHORS | 0 .../{ => webview_flutter}/CHANGELOG.md | 0 .../webview_flutter/{ => webview_flutter}/LICENSE | 0 .../webview_flutter/{ => webview_flutter}/README.md | 0 .../{ => webview_flutter}/android/build.gradle | 0 .../{ => webview_flutter}/android/settings.gradle | 0 .../android/src/main/AndroidManifest.xml | 0 .../webviewflutter/DisplayListenerProxy.java | 0 .../webviewflutter/FlutterCookieManager.java | 0 .../plugins/webviewflutter/FlutterWebView.java | 0 .../webviewflutter/FlutterWebViewClient.java | 0 .../plugins/webviewflutter/InputAwareWebView.java | 0 .../plugins/webviewflutter/JavaScriptChannel.java | 0 .../ThreadedInputConnectionProxyAdapterView.java | 0 .../plugins/webviewflutter/WebViewFactory.java | 0 .../webviewflutter/WebViewFlutterPlugin.java | 0 .../flutter/plugins/webviewflutter/WebViewTest.java | 0 .../{ => webview_flutter}/example/.metadata | 0 .../{ => webview_flutter}/example/README.md | 0 .../example/android/app/build.gradle | 0 .../app/gradle/wrapper/gradle-wrapper.properties | 0 .../EmbeddingV1ActivityTest.java | 0 .../webviewflutterexample/MainActivityTest.java | 0 .../android/app/src/main/AndroidManifest.xml | 0 .../app/src/main/res/drawable/launch_background.xml | 0 .../app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin .../app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin .../app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin .../app/src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin .../app/src/main/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../android/app/src/main/res/values/styles.xml | 0 .../example/android/build.gradle | 0 .../example/android/gradle.properties | 0 .../gradle/wrapper/gradle-wrapper.properties | 0 .../example/android/settings.gradle | 0 .../example/assets/sample_audio.ogg | Bin .../example/assets/sample_video.mp4 | Bin .../integration_test/webview_flutter_test.dart | 0 .../example/ios/Flutter/AppFrameworkInfo.plist | 0 .../example/ios/Flutter/Debug.xcconfig | 0 .../example/ios/Flutter/Release.xcconfig | 0 .../{ => webview_flutter}/example/ios/Podfile | 0 .../example/ios/Runner.xcodeproj/project.pbxproj | 0 .../project.xcworkspace/contents.xcworkspacedata | 0 .../xcshareddata/xcschemes/Runner.xcscheme | 0 .../ios/Runner.xcworkspace/contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../example/ios/Runner/AppDelegate.h | 0 .../example/ios/Runner/AppDelegate.m | 0 .../AppIcon.appiconset/Contents.json | 0 .../AppIcon.appiconset/Icon-App-1024x1024@1x.png | Bin .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin .../AppIcon.appiconset/Icon-App-83.5x83.5@2x.png | Bin .../LaunchImage.imageset/Contents.json | 0 .../LaunchImage.imageset/LaunchImage.png | Bin .../LaunchImage.imageset/LaunchImage@2x.png | Bin .../LaunchImage.imageset/LaunchImage@3x.png | Bin .../Assets.xcassets/LaunchImage.imageset/README.md | 0 .../ios/Runner/Base.lproj/LaunchScreen.storyboard | 0 .../example/ios/Runner/Base.lproj/Main.storyboard | 0 .../example/ios/Runner/Info.plist | 0 .../{ => webview_flutter}/example/ios/Runner/main.m | 0 .../ios/RunnerTests/FLTWKNavigationDelegateTests.m | 0 .../example/ios/RunnerTests/FLTWebViewTests.m | 0 .../example/ios/RunnerTests/Info.plist | 0 .../example/ios/RunnerUITests/FLTWebViewUITests.m | 0 .../example/ios/RunnerUITests/Info.plist | 0 .../{ => webview_flutter}/example/lib/main.dart | 0 .../{ => webview_flutter}/example/pubspec.yaml | 0 .../example/test_driver/integration_test.dart | 0 .../{ => webview_flutter}/ios/Assets/.gitkeep | 0 .../ios/Classes/FLTCookieManager.h | 0 .../ios/Classes/FLTCookieManager.m | 0 .../ios/Classes/FLTWKNavigationDelegate.h | 0 .../ios/Classes/FLTWKNavigationDelegate.m | 0 .../ios/Classes/FLTWKProgressionDelegate.h | 0 .../ios/Classes/FLTWKProgressionDelegate.m | 0 .../ios/Classes/FLTWebViewFlutterPlugin.h | 0 .../ios/Classes/FLTWebViewFlutterPlugin.m | 0 .../ios/Classes/FlutterWebView.h | 0 .../ios/Classes/FlutterWebView.m | 0 .../ios/Classes/JavaScriptChannelHandler.h | 0 .../ios/Classes/JavaScriptChannelHandler.m | 0 .../ios/webview_flutter.podspec | 0 .../lib/platform_interface.dart | 0 .../lib/src/webview_android.dart | 0 .../lib/src/webview_cupertino.dart | 0 .../lib/src/webview_method_channel.dart | 0 .../{ => webview_flutter}/lib/webview_flutter.dart | 0 .../{ => webview_flutter}/pubspec.yaml | 2 +- .../test/webview_flutter_test.dart | 0 103 files changed, 1 insertion(+), 1 deletion(-) rename packages/webview_flutter/{ => webview_flutter}/AUTHORS (100%) rename packages/webview_flutter/{ => webview_flutter}/CHANGELOG.md (100%) rename packages/webview_flutter/{ => webview_flutter}/LICENSE (100%) rename packages/webview_flutter/{ => webview_flutter}/README.md (100%) rename packages/webview_flutter/{ => webview_flutter}/android/build.gradle (100%) rename packages/webview_flutter/{ => webview_flutter}/android/settings.gradle (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/AndroidManifest.xml (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java (100%) rename packages/webview_flutter/{ => webview_flutter}/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java (100%) rename packages/webview_flutter/{ => webview_flutter}/example/.metadata (100%) rename packages/webview_flutter/{ => webview_flutter}/example/README.md (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/build.gradle (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/gradle/wrapper/gradle-wrapper.properties (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/AndroidManifest.xml (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/drawable/launch_background.xml (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/app/src/main/res/values/styles.xml (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/build.gradle (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/gradle.properties (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/gradle/wrapper/gradle-wrapper.properties (100%) rename packages/webview_flutter/{ => webview_flutter}/example/android/settings.gradle (100%) rename packages/webview_flutter/{ => webview_flutter}/example/assets/sample_audio.ogg (100%) rename packages/webview_flutter/{ => webview_flutter}/example/assets/sample_video.mp4 (100%) rename packages/webview_flutter/{ => webview_flutter}/example/integration_test/webview_flutter_test.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Flutter/AppFrameworkInfo.plist (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Flutter/Debug.xcconfig (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Flutter/Release.xcconfig (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Podfile (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner.xcodeproj/project.pbxproj (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner.xcworkspace/contents.xcworkspacedata (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/AppDelegate.h (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/AppDelegate.m (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Base.lproj/LaunchScreen.storyboard (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Base.lproj/Main.storyboard (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/Info.plist (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/Runner/main.m (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/RunnerTests/FLTWebViewTests.m (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/RunnerTests/Info.plist (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/RunnerUITests/FLTWebViewUITests.m (100%) rename packages/webview_flutter/{ => webview_flutter}/example/ios/RunnerUITests/Info.plist (100%) rename packages/webview_flutter/{ => webview_flutter}/example/lib/main.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/example/pubspec.yaml (100%) rename packages/webview_flutter/{ => webview_flutter}/example/test_driver/integration_test.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Assets/.gitkeep (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTCookieManager.h (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTCookieManager.m (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTWKNavigationDelegate.h (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTWKNavigationDelegate.m (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTWKProgressionDelegate.h (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTWKProgressionDelegate.m (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTWebViewFlutterPlugin.h (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FLTWebViewFlutterPlugin.m (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FlutterWebView.h (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/FlutterWebView.m (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/JavaScriptChannelHandler.h (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/Classes/JavaScriptChannelHandler.m (100%) rename packages/webview_flutter/{ => webview_flutter}/ios/webview_flutter.podspec (100%) rename packages/webview_flutter/{ => webview_flutter}/lib/platform_interface.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/lib/src/webview_android.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/lib/src/webview_cupertino.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/lib/src/webview_method_channel.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/lib/webview_flutter.dart (100%) rename packages/webview_flutter/{ => webview_flutter}/pubspec.yaml (95%) rename packages/webview_flutter/{ => webview_flutter}/test/webview_flutter_test.dart (100%) diff --git a/packages/webview_flutter/AUTHORS b/packages/webview_flutter/webview_flutter/AUTHORS similarity index 100% rename from packages/webview_flutter/AUTHORS rename to packages/webview_flutter/webview_flutter/AUTHORS diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md similarity index 100% rename from packages/webview_flutter/CHANGELOG.md rename to packages/webview_flutter/webview_flutter/CHANGELOG.md diff --git a/packages/webview_flutter/LICENSE b/packages/webview_flutter/webview_flutter/LICENSE similarity index 100% rename from packages/webview_flutter/LICENSE rename to packages/webview_flutter/webview_flutter/LICENSE diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/webview_flutter/README.md similarity index 100% rename from packages/webview_flutter/README.md rename to packages/webview_flutter/webview_flutter/README.md diff --git a/packages/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle similarity index 100% rename from packages/webview_flutter/android/build.gradle rename to packages/webview_flutter/webview_flutter/android/build.gradle diff --git a/packages/webview_flutter/android/settings.gradle b/packages/webview_flutter/webview_flutter/android/settings.gradle similarity index 100% rename from packages/webview_flutter/android/settings.gradle rename to packages/webview_flutter/webview_flutter/android/settings.gradle diff --git a/packages/webview_flutter/android/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml similarity index 100% rename from packages/webview_flutter/android/src/main/AndroidManifest.xml rename to packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java similarity index 100% rename from packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java diff --git a/packages/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java similarity index 100% rename from packages/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java rename to packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java diff --git a/packages/webview_flutter/example/.metadata b/packages/webview_flutter/webview_flutter/example/.metadata similarity index 100% rename from packages/webview_flutter/example/.metadata rename to packages/webview_flutter/webview_flutter/example/.metadata diff --git a/packages/webview_flutter/example/README.md b/packages/webview_flutter/webview_flutter/example/README.md similarity index 100% rename from packages/webview_flutter/example/README.md rename to packages/webview_flutter/webview_flutter/example/README.md diff --git a/packages/webview_flutter/example/android/app/build.gradle b/packages/webview_flutter/webview_flutter/example/android/app/build.gradle similarity index 100% rename from packages/webview_flutter/example/android/app/build.gradle rename to packages/webview_flutter/webview_flutter/example/android/app/build.gradle diff --git a/packages/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from packages/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties rename to packages/webview_flutter/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties diff --git a/packages/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java similarity index 100% rename from packages/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java rename to packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java diff --git a/packages/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java similarity index 100% rename from packages/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java rename to packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java diff --git a/packages/webview_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/AndroidManifest.xml rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml diff --git a/packages/webview_flutter/example/android/app/src/main/res/drawable/launch_background.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/drawable/launch_background.xml rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/drawable/launch_background.xml diff --git a/packages/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/packages/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/packages/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/packages/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/packages/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/packages/webview_flutter/example/android/app/src/main/res/values/styles.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/values/styles.xml similarity index 100% rename from packages/webview_flutter/example/android/app/src/main/res/values/styles.xml rename to packages/webview_flutter/webview_flutter/example/android/app/src/main/res/values/styles.xml diff --git a/packages/webview_flutter/example/android/build.gradle b/packages/webview_flutter/webview_flutter/example/android/build.gradle similarity index 100% rename from packages/webview_flutter/example/android/build.gradle rename to packages/webview_flutter/webview_flutter/example/android/build.gradle diff --git a/packages/webview_flutter/example/android/gradle.properties b/packages/webview_flutter/webview_flutter/example/android/gradle.properties similarity index 100% rename from packages/webview_flutter/example/android/gradle.properties rename to packages/webview_flutter/webview_flutter/example/android/gradle.properties diff --git a/packages/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from packages/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties rename to packages/webview_flutter/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties diff --git a/packages/webview_flutter/example/android/settings.gradle b/packages/webview_flutter/webview_flutter/example/android/settings.gradle similarity index 100% rename from packages/webview_flutter/example/android/settings.gradle rename to packages/webview_flutter/webview_flutter/example/android/settings.gradle diff --git a/packages/webview_flutter/example/assets/sample_audio.ogg b/packages/webview_flutter/webview_flutter/example/assets/sample_audio.ogg similarity index 100% rename from packages/webview_flutter/example/assets/sample_audio.ogg rename to packages/webview_flutter/webview_flutter/example/assets/sample_audio.ogg diff --git a/packages/webview_flutter/example/assets/sample_video.mp4 b/packages/webview_flutter/webview_flutter/example/assets/sample_video.mp4 similarity index 100% rename from packages/webview_flutter/example/assets/sample_video.mp4 rename to packages/webview_flutter/webview_flutter/example/assets/sample_video.mp4 diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart similarity index 100% rename from packages/webview_flutter/example/integration_test/webview_flutter_test.dart rename to packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart diff --git a/packages/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from packages/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist rename to packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist diff --git a/packages/webview_flutter/example/ios/Flutter/Debug.xcconfig b/packages/webview_flutter/webview_flutter/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from packages/webview_flutter/example/ios/Flutter/Debug.xcconfig rename to packages/webview_flutter/webview_flutter/example/ios/Flutter/Debug.xcconfig diff --git a/packages/webview_flutter/example/ios/Flutter/Release.xcconfig b/packages/webview_flutter/webview_flutter/example/ios/Flutter/Release.xcconfig similarity index 100% rename from packages/webview_flutter/example/ios/Flutter/Release.xcconfig rename to packages/webview_flutter/webview_flutter/example/ios/Flutter/Release.xcconfig diff --git a/packages/webview_flutter/example/ios/Podfile b/packages/webview_flutter/webview_flutter/example/ios/Podfile similarity index 100% rename from packages/webview_flutter/example/ios/Podfile rename to packages/webview_flutter/webview_flutter/example/ios/Podfile diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from packages/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj rename to packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from packages/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from packages/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/packages/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from packages/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/packages/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from packages/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/packages/webview_flutter/example/ios/Runner/AppDelegate.h b/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.h similarity index 100% rename from packages/webview_flutter/example/ios/Runner/AppDelegate.h rename to packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.h diff --git a/packages/webview_flutter/example/ios/Runner/AppDelegate.m b/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.m similarity index 100% rename from packages/webview_flutter/example/ios/Runner/AppDelegate.m rename to packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.m diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/packages/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/packages/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard b/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard diff --git a/packages/webview_flutter/example/ios/Runner/Info.plist b/packages/webview_flutter/webview_flutter/example/ios/Runner/Info.plist similarity index 100% rename from packages/webview_flutter/example/ios/Runner/Info.plist rename to packages/webview_flutter/webview_flutter/example/ios/Runner/Info.plist diff --git a/packages/webview_flutter/example/ios/Runner/main.m b/packages/webview_flutter/webview_flutter/example/ios/Runner/main.m similarity index 100% rename from packages/webview_flutter/example/ios/Runner/main.m rename to packages/webview_flutter/webview_flutter/example/ios/Runner/main.m diff --git a/packages/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m similarity index 100% rename from packages/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m rename to packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m diff --git a/packages/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m similarity index 100% rename from packages/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m rename to packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m diff --git a/packages/webview_flutter/example/ios/RunnerTests/Info.plist b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/Info.plist similarity index 100% rename from packages/webview_flutter/example/ios/RunnerTests/Info.plist rename to packages/webview_flutter/webview_flutter/example/ios/RunnerTests/Info.plist diff --git a/packages/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m similarity index 100% rename from packages/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m rename to packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m diff --git a/packages/webview_flutter/example/ios/RunnerUITests/Info.plist b/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/Info.plist similarity index 100% rename from packages/webview_flutter/example/ios/RunnerUITests/Info.plist rename to packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/Info.plist diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/webview_flutter/example/lib/main.dart similarity index 100% rename from packages/webview_flutter/example/lib/main.dart rename to packages/webview_flutter/webview_flutter/example/lib/main.dart diff --git a/packages/webview_flutter/example/pubspec.yaml b/packages/webview_flutter/webview_flutter/example/pubspec.yaml similarity index 100% rename from packages/webview_flutter/example/pubspec.yaml rename to packages/webview_flutter/webview_flutter/example/pubspec.yaml diff --git a/packages/webview_flutter/example/test_driver/integration_test.dart b/packages/webview_flutter/webview_flutter/example/test_driver/integration_test.dart similarity index 100% rename from packages/webview_flutter/example/test_driver/integration_test.dart rename to packages/webview_flutter/webview_flutter/example/test_driver/integration_test.dart diff --git a/packages/webview_flutter/ios/Assets/.gitkeep b/packages/webview_flutter/webview_flutter/ios/Assets/.gitkeep similarity index 100% rename from packages/webview_flutter/ios/Assets/.gitkeep rename to packages/webview_flutter/webview_flutter/ios/Assets/.gitkeep diff --git a/packages/webview_flutter/ios/Classes/FLTCookieManager.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.h similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTCookieManager.h rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.h diff --git a/packages/webview_flutter/ios/Classes/FLTCookieManager.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTCookieManager.m rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m diff --git a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h diff --git a/packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m diff --git a/packages/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h diff --git a/packages/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m diff --git a/packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h diff --git a/packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m similarity index 100% rename from packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m rename to packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.h b/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.h similarity index 100% rename from packages/webview_flutter/ios/Classes/FlutterWebView.h rename to packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.h diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m similarity index 100% rename from packages/webview_flutter/ios/Classes/FlutterWebView.m rename to packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m diff --git a/packages/webview_flutter/ios/Classes/JavaScriptChannelHandler.h b/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.h similarity index 100% rename from packages/webview_flutter/ios/Classes/JavaScriptChannelHandler.h rename to packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.h diff --git a/packages/webview_flutter/ios/Classes/JavaScriptChannelHandler.m b/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.m similarity index 100% rename from packages/webview_flutter/ios/Classes/JavaScriptChannelHandler.m rename to packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.m diff --git a/packages/webview_flutter/ios/webview_flutter.podspec b/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec similarity index 100% rename from packages/webview_flutter/ios/webview_flutter.podspec rename to packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec diff --git a/packages/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/webview_flutter/lib/platform_interface.dart similarity index 100% rename from packages/webview_flutter/lib/platform_interface.dart rename to packages/webview_flutter/webview_flutter/lib/platform_interface.dart diff --git a/packages/webview_flutter/lib/src/webview_android.dart b/packages/webview_flutter/webview_flutter/lib/src/webview_android.dart similarity index 100% rename from packages/webview_flutter/lib/src/webview_android.dart rename to packages/webview_flutter/webview_flutter/lib/src/webview_android.dart diff --git a/packages/webview_flutter/lib/src/webview_cupertino.dart b/packages/webview_flutter/webview_flutter/lib/src/webview_cupertino.dart similarity index 100% rename from packages/webview_flutter/lib/src/webview_cupertino.dart rename to packages/webview_flutter/webview_flutter/lib/src/webview_cupertino.dart diff --git a/packages/webview_flutter/lib/src/webview_method_channel.dart b/packages/webview_flutter/webview_flutter/lib/src/webview_method_channel.dart similarity index 100% rename from packages/webview_flutter/lib/src/webview_method_channel.dart rename to packages/webview_flutter/webview_flutter/lib/src/webview_method_channel.dart diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart similarity index 100% rename from packages/webview_flutter/lib/webview_flutter.dart rename to packages/webview_flutter/webview_flutter/lib/webview_flutter.dart diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml similarity index 95% rename from packages/webview_flutter/pubspec.yaml rename to packages/webview_flutter/webview_flutter/pubspec.yaml index 4d984beeed96..88ab4ad7927e 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. -repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 version: 2.0.10 diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart similarity index 100% rename from packages/webview_flutter/test/webview_flutter_test.dart rename to packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart From 337dc68d7b67eee25ac6ca7ebf071751a9d760c9 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 20 Jul 2021 21:30:50 +0200 Subject: [PATCH 142/364] [camera_web] Add Camera class to control video and take pictures (#4168) --- .../example/integration_test/camera_test.dart | 487 ++++++++++++++++++ .../camera/camera_web/lib/src/camera.dart | 196 +++++++ .../camera_web/lib/src/shims/dart_ui.dart | 10 + .../lib/src/shims/dart_ui_fake.dart | 28 + .../lib/src/shims/dart_ui_real.dart | 5 + .../lib/src/types/camera_error_codes.dart | 29 ++ .../camera_web/lib/src/types/types.dart | 1 + 7 files changed, 756 insertions(+) create mode 100644 packages/camera/camera_web/example/integration_test/camera_test.dart create mode 100644 packages/camera/camera_web/lib/src/camera.dart create mode 100644 packages/camera/camera_web/lib/src/shims/dart_ui.dart create mode 100644 packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart create mode 100644 packages/camera/camera_web/lib/src/shims/dart_ui_real.dart create mode 100644 packages/camera/camera_web/lib/src/types/camera_error_codes.dart diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart new file mode 100644 index 000000000000..0f1dcf7049d9 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -0,0 +1,487 @@ +// Copyright 2013 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:html'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera.dart'; +import 'package:camera_web/src/types/camera_error_codes.dart'; +import 'package:camera_web/src/types/camera_options.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Camera', () { + late Window window; + late Navigator navigator; + late MediaStream mediaStream; + late MediaDevices mediaDevices; + + setUp(() { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + + final videoElement = VideoElement() + ..src = + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' + ..preload = 'true' + ..width = 10 + ..height = 10; + + mediaStream = videoElement.captureStream(); + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + when( + () => mediaDevices.getUserMedia(any()), + ).thenAnswer((_) async => mediaStream); + }); + + group('initialize', () { + testWidgets( + 'creates a video element ' + 'with correct properties', (tester) async { + const audioConstraints = AudioConstraints(enabled: true); + + final camera = Camera( + textureId: 1, + options: CameraOptions( + audio: audioConstraints, + ), + window: window, + ); + + await camera.initialize(); + + expect(camera.videoElement, isNotNull); + expect(camera.videoElement.autoplay, isFalse); + expect(camera.videoElement.muted, !audioConstraints.enabled); + expect(camera.videoElement.srcObject, mediaStream); + expect(camera.videoElement.attributes.keys, contains('playsinline')); + + expect( + camera.videoElement.style.transformOrigin, equals('center center')); + expect(camera.videoElement.style.pointerEvents, equals('none')); + expect(camera.videoElement.style.width, equals('100%')); + expect(camera.videoElement.style.height, equals('100%')); + expect(camera.videoElement.style.objectFit, equals('cover')); + expect(camera.videoElement.style.transform, equals('scaleX(-1)')); + }); + + testWidgets( + 'creates a wrapping div element ' + 'with correct properties', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + expect(camera.divElement, isNotNull); + expect(camera.divElement.style.objectFit, equals('cover')); + expect(camera.divElement.children, contains(camera.videoElement)); + }); + + testWidgets('calls getUserMedia with provided options', (tester) async { + final options = CameraOptions( + video: VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + width: VideoSizeConstraint(ideal: 200), + ), + ); + + final optionsJson = await options.toJson(); + + final camera = Camera( + textureId: 1, + options: options, + window: window, + ); + + await camera.initialize(); + + verify(() => mediaDevices.getUserMedia(optionsJson)).called(1); + }); + + group('throws CameraException', () { + testWidgets( + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notSupported, + ), + ), + ); + }); + + testWidgets( + 'with notFound error ' + 'when getUserMedia throws DomException ' + 'with NotFoundError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotFoundError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets( + 'with notFound error ' + 'when getUserMedia throws DomException ' + 'with DevicesNotFoundError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('DevicesNotFoundError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets( + 'with notReadable error ' + 'when getUserMedia throws DomException ' + 'with NotReadableError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotReadableError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notReadable, + ), + ), + ); + }); + + testWidgets( + 'with notReadable error ' + 'when getUserMedia throws DomException ' + 'with TrackStartError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('TrackStartError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notReadable, + ), + ), + ); + }); + + testWidgets( + 'with overconstrained error ' + 'when getUserMedia throws DomException ' + 'with OverconstrainedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('OverconstrainedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.overconstrained, + ), + ), + ); + }); + + testWidgets( + 'with overconstrained error ' + 'when getUserMedia throws DomException ' + 'with ConstraintNotSatisfiedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('ConstraintNotSatisfiedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.overconstrained, + ), + ), + ); + }); + + testWidgets( + 'with permissionDenied error ' + 'when getUserMedia throws DomException ' + 'with NotAllowedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotAllowedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.permissionDenied, + ), + ), + ); + }); + + testWidgets( + 'with permissionDenied error ' + 'when getUserMedia throws DomException ' + 'with PermissionDeniedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('PermissionDeniedError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.permissionDenied, + ), + ), + ); + }); + + testWidgets( + 'with type error ' + 'when getUserMedia throws DomException ' + 'with TypeError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('TypeError')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.type, + ), + ), + ); + }); + + testWidgets( + 'with unknown error ' + 'when getUserMedia throws DomException ' + 'with an unknown error', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('Unknown')); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.unknown, + ), + ), + ); + }); + + testWidgets( + 'with unknown error ' + 'when getUserMedia throws an unknown exception', (tester) async { + when(() => mediaDevices.getUserMedia(any())).thenThrow(Exception()); + + final camera = Camera( + textureId: 1, + window: window, + ); + + expect( + camera.initialize, + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.unknown, + ), + ), + ); + }); + }); + }); + + group('play', () { + testWidgets('starts playing the video element', (tester) async { + var startedPlaying = false; + + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + camera.videoElement.onPlay.listen((event) => startedPlaying = true); + + await camera.play(); + + expect(startedPlaying, isTrue); + }); + + testWidgets( + 'assigns media stream to the video element\'s source ' + 'if it does not exist', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + /// Remove the video element's source + /// by stopping the camera. + // ignore: cascade_invocations + camera.stop(); + + await camera.play(); + + expect(camera.videoElement.srcObject, mediaStream); + }); + }); + + group('stop', () { + testWidgets('resets the video element\'s source', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + await camera.play(); + + camera.stop(); + + expect(camera.videoElement.srcObject, isNull); + }); + }); + + group('takePicture', () { + testWidgets('returns a captured picture', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + await camera.play(); + + final pictureFile = await camera.takePicture(); + + expect(pictureFile, isNotNull); + }); + }); + + group('dispose', () { + testWidgets('resets the video element\'s source', (tester) async { + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + camera.dispose(); + + expect(camera.videoElement.srcObject, isNull); + }); + }); + }); +} diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart new file mode 100644 index 000000000000..41692d548882 --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -0,0 +1,196 @@ +// Copyright 2013 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:html' as html; +import 'shims/dart_ui.dart' as ui; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/types/camera_error_codes.dart'; +import 'package:camera_web/src/types/camera_options.dart'; + +String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; + +/// A camera initialized from the media devices in the current [window]. +/// The obtained camera is constrained by the [options] used when +/// querying the media input in [_getMediaStream]. +/// +/// The camera stream is displayed in the [videoElement] wrapped in the +/// [divElement] to avoid overriding the custom styles applied to +/// the video element in [_applyDefaultVideoStyles]. +/// See: https://github.com/flutter/flutter/issues/79519 +/// +/// The camera can be played/stopped by calling [play]/[stop] +/// or may capture a picture by [takePicture]. +/// +/// The [textureId] is used to register a camera view with the id +/// returned by [_getViewType]. +class Camera { + /// Creates a new instance of [Camera] + /// with the given [textureId] and optional + /// [options] and [window]. + Camera({ + required this.textureId, + this.options = const CameraOptions(), + html.Window? window, + }) : window = window ?? html.window; + + /// The texture id used to register the camera view. + final int textureId; + + /// The camera options used to initialize a camera, empty by default. + final CameraOptions options; + + /// The current browser window used to access device cameras. + final html.Window window; + + /// The video element that displays the camera stream. + /// Initialized in [initialize]. + late html.VideoElement videoElement; + + /// The wrapping element for the [videoElement] to avoid overriding + /// the custom styles applied in [_applyDefaultVideoStyles]. + /// Initialized in [initialize]. + late html.DivElement divElement; + + /// Initializes the camera stream displayed in the [videoElement]. + /// Registers the camera view with [textureId] under [_getViewType] type. + Future initialize() async { + final isSupported = window.navigator.mediaDevices?.getUserMedia != null; + if (!isSupported) { + throw CameraException( + CameraErrorCodes.notSupported, + 'The camera is not supported on this device.', + ); + } + + videoElement = html.VideoElement(); + _applyDefaultVideoStyles(videoElement); + + divElement = html.DivElement() + ..style.setProperty('object-fit', 'cover') + ..append(videoElement); + + ui.platformViewRegistry.registerViewFactory( + _getViewType(textureId), + (_) => divElement, + ); + + final stream = await _getMediaStream(); + videoElement + ..autoplay = false + ..muted = !options.audio.enabled + ..srcObject = stream + ..setAttribute('playsinline', ''); + } + + Future _getMediaStream() async { + try { + final constraints = await options.toJson(); + return await window.navigator.mediaDevices!.getUserMedia(constraints); + } on html.DomException catch (e) { + switch (e.name) { + case 'NotFoundError': + case 'DevicesNotFoundError': + throw CameraException( + CameraErrorCodes.notFound, + 'No camera found for the given camera options.', + ); + case 'NotReadableError': + case 'TrackStartError': + throw CameraException( + CameraErrorCodes.notReadable, + 'The camera is not readable due to a hardware error ' + 'that prevented access to the device.', + ); + case 'OverconstrainedError': + case 'ConstraintNotSatisfiedError': + throw CameraException( + CameraErrorCodes.overconstrained, + 'The camera options are impossible to satisfy.', + ); + case 'NotAllowedError': + case 'PermissionDeniedError': + throw CameraException( + CameraErrorCodes.permissionDenied, + 'The camera cannot be used or the permission ' + 'to access the camera is not granted.', + ); + case 'TypeError': + throw CameraException( + CameraErrorCodes.type, + 'The camera options are incorrect or attempted' + 'to access the media input from an insecure context.', + ); + default: + throw CameraException( + CameraErrorCodes.unknown, + 'An unknown error occured when initializing the camera.', + ); + } + } catch (_) { + throw CameraException( + CameraErrorCodes.unknown, + 'An unknown error occured when initializing the camera.', + ); + } + } + + /// Starts the camera stream. + /// + /// Initializes the camera source if the camera was previously stopped. + Future play() async { + if (videoElement.srcObject == null) { + final stream = await _getMediaStream(); + videoElement.srcObject = stream; + } + await videoElement.play(); + } + + /// Stops the camera stream and resets the camera source. + void stop() { + final tracks = videoElement.srcObject?.getTracks(); + if (tracks != null) { + for (final track in tracks) { + track.stop(); + } + } + videoElement.srcObject = null; + } + + /// Captures a picture and returns the saved file in a JPEG format. + Future takePicture() async { + final videoWidth = videoElement.videoWidth; + final videoHeight = videoElement.videoHeight; + final canvas = html.CanvasElement(width: videoWidth, height: videoHeight); + canvas.context2D + ..translate(videoWidth, 0) + ..scale(-1, 1) + ..drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); + final blob = await canvas.toBlob('image/jpeg'); + return XFile(html.Url.createObjectUrl(blob)); + } + + /// Disposes the camera by stopping the camera stream + /// and reloading the camera source. + void dispose() { + /// Stop the camera stream. + stop(); + + /// Reset the [videoElement] to its initial state. + videoElement + ..srcObject = null + ..load(); + } + + /// Applies default styles to the video [element]. + void _applyDefaultVideoStyles(html.VideoElement element) { + element.style + ..transformOrigin = 'center' + ..pointerEvents = 'none' + ..width = '100%' + ..height = '100%' + ..objectFit = 'cover' + ..transform = 'scaleX(-1)'; + } +} diff --git a/packages/camera/camera_web/lib/src/shims/dart_ui.dart b/packages/camera/camera_web/lib/src/shims/dart_ui.dart new file mode 100644 index 000000000000..5eacec5fe867 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_ui.dart @@ -0,0 +1,10 @@ +// Copyright 2013 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. + +/// This file shims dart:ui in web-only scenarios, getting rid of the need to +/// suppress analyzer warnings. + +// TODO(flutter/flutter#55000) Remove this file once web-only dart:ui APIs +// are exposed from a dedicated place. +export 'dart_ui_fake.dart' if (dart.library.html) 'dart_ui_real.dart'; diff --git a/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart b/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart new file mode 100644 index 000000000000..f2862af8b704 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart @@ -0,0 +1,28 @@ +// Copyright 2013 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:html' as html; + +// Fake interface for the logic that this package needs from (web-only) dart:ui. +// This is conditionally exported so the analyzer sees these methods as available. + +/// Shim for web_ui engine.PlatformViewRegistry +/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L62 +class platformViewRegistry { + /// Shim for registerViewFactory + /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L72 + static registerViewFactory( + String viewTypeId, html.Element Function(int viewId) viewFactory) {} +} + +/// Shim for web_ui engine.AssetManager. +/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L12 +class webOnlyAssetManager { + /// Shim for getAssetUrl. + /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L45 + static getAssetUrl(String asset) {} +} + +/// Signature of callbacks that have no arguments and return no data. +typedef VoidCallback = void Function(); diff --git a/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart b/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart new file mode 100644 index 000000000000..276b768c76c5 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart @@ -0,0 +1,5 @@ +// Copyright 2013 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. + +export 'dart:ui'; diff --git a/packages/camera/camera_web/lib/src/types/camera_error_codes.dart b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart new file mode 100644 index 000000000000..f8dc5dfc4e32 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart @@ -0,0 +1,29 @@ +// Copyright 2013 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. + +/// Error codes that may occur during the camera initialization or streaming. +abstract class CameraErrorCodes { + /// The camera is not supported. + static const notSupported = 'cameraNotSupported'; + + /// The camera is not found. + static const notFound = 'cameraNotFound'; + + /// The camera is not readable. + static const notReadable = 'cameraNotReadable'; + + /// The camera options are impossible to satisfy. + static const overconstrained = 'cameraOverconstrained'; + + /// The camera cannot be used or the permission + /// to access the camera is not granted. + static const permissionDenied = 'cameraPermission'; + + /// The camera options are incorrect or attempted + /// to access the media input from an insecure context. + static const type = 'cameraType'; + + /// An unknown camera error. + static const unknown = 'cameraUnknown'; +} diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart index deccd32da4c0..fc1f931679ff 100644 --- a/packages/camera/camera_web/lib/src/types/types.dart +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -2,4 +2,5 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'camera_error_codes.dart'; export 'camera_options.dart'; From 09ec5192f3ce31622631458ba9655cc257a07951 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 20 Jul 2021 18:17:20 -0700 Subject: [PATCH 143/364] [flutter_plugin_tests] Split analyze out of xctest (#4161) To prep for making a combined command to run native tests across different platforms, rework `xctest`: - Split analyze out into a new `xcode-analyze` command: - Since the analyze step runs a new build over everything with different flags, this is only a small amount slower than the combined version - This makes the logic easier to follow - This allows us to meaningfully report skips, to better notice missing tests. - Add the ability to target specific test bundles (RunnerTests or RunnerUITests) To share code between the commands, this extracts a new `Xcode` helper class. Part of https://github.com/flutter/flutter/issues/84392 and https://github.com/flutter/flutter/issues/86489 --- .cirrus.yml | 4 + script/tool/CHANGELOG.md | 6 + script/tool/lib/src/common/xcode.dart | 159 +++++++ script/tool/lib/src/main.dart | 2 + .../tool/lib/src/xcode_analyze_command.dart | 111 +++++ script/tool/lib/src/xctest_command.dart | 219 ++++----- script/tool/test/common/xcode_test.dart | 396 ++++++++++++++++ .../tool/test/xcode_analyze_command_test.dart | 416 +++++++++++++++++ script/tool/test/xctest_command_test.dart | 427 +++++++++++------- 9 files changed, 1444 insertions(+), 296 deletions(-) create mode 100644 script/tool/lib/src/common/xcode.dart create mode 100644 script/tool/lib/src/xcode_analyze_command.dart create mode 100644 script/tool/test/common/xcode_test.dart create mode 100644 script/tool/test/xcode_analyze_command_test.dart diff --git a/.cirrus.yml b/.cirrus.yml index 8f69bd188c06..bf5675b6e3ae 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -221,6 +221,8 @@ task: - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-5 | xargs xcrun simctl boot build_script: - ./script/tool_runner.sh build-examples --ios + xcode_analyze_script: + - ./script/tool_runner.sh xcode-analyze --ios xctest_script: - ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" drive_script: @@ -249,6 +251,8 @@ task: build_script: - flutter config --enable-macos-desktop - ./script/tool_runner.sh build-examples --macos + xcode_analyze_script: + - ./script/tool_runner.sh xcode-analyze --macos xctest_script: - ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS drive_script: diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 1e447721d13f..377e7860bd26 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,9 @@ +## NEXT + +- Added an `xctest` flag to select specific test targets, to allow running only + unit tests or integration tests. +- Split Xcode analysis out of `xctest` and into a new `xcode-analyze` command. + ## 0.4.1 - Improved `license-check` output. diff --git a/script/tool/lib/src/common/xcode.dart b/script/tool/lib/src/common/xcode.dart new file mode 100644 index 000000000000..d6bbae419eda --- /dev/null +++ b/script/tool/lib/src/common/xcode.dart @@ -0,0 +1,159 @@ +// Copyright 2013 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 'core.dart'; +import 'process_runner.dart'; + +const String _xcodeBuildCommand = 'xcodebuild'; +const String _xcRunCommand = 'xcrun'; + +/// A utility class for interacting with the installed version of Xcode. +class Xcode { + /// Creates an instance that runs commends with the given [processRunner]. + /// + /// If [log] is true, commands run by this instance will long various status + /// messages. + Xcode({ + this.processRunner = const ProcessRunner(), + this.log = false, + }); + + /// The [ProcessRunner] used to run commands. Overridable for testing. + final ProcessRunner processRunner; + + /// Whether or not to log when running commands. + final bool log; + + /// Runs an `xcodebuild` in [directory] with the given parameters. + Future runXcodeBuild( + Directory directory, { + List actions = const ['build'], + required String workspace, + required String scheme, + String? configuration, + List extraFlags = const [], + }) { + final List args = [ + _xcodeBuildCommand, + ...actions, + if (workspace != null) ...['-workspace', workspace], + if (scheme != null) ...['-scheme', scheme], + if (configuration != null) ...['-configuration', configuration], + ...extraFlags, + ]; + final String completeTestCommand = '$_xcRunCommand ${args.join(' ')}'; + if (log) { + print(completeTestCommand); + } + return processRunner.runAndStream(_xcRunCommand, args, + workingDir: directory); + } + + /// Returns true if [project], which should be an .xcodeproj directory, + /// contains a target called [target], false if it does not, and null if the + /// check fails (e.g., if [project] is not an Xcode project). + Future projectHasTarget(Directory project, String target) async { + final io.ProcessResult result = + await processRunner.run(_xcRunCommand, [ + _xcodeBuildCommand, + '-list', + '-json', + '-project', + project.path, + ]); + if (result.exitCode != 0) { + return null; + } + Map? projectInfo; + try { + projectInfo = (jsonDecode(result.stdout as String) + as Map)['project'] as Map?; + } on FormatException { + return null; + } + if (projectInfo == null) { + return null; + } + final List? targets = + (projectInfo['targets'] as List?)?.cast(); + return targets?.contains(target) ?? false; + } + + /// Returns the newest available simulator (highest OS version, with ties + /// broken in favor of newest device), if any. + Future findBestAvailableIphoneSimulator() async { + final List findSimulatorsArguments = [ + 'simctl', + 'list', + 'devices', + 'runtimes', + 'available', + '--json', + ]; + final String findSimulatorCompleteCommand = + '$_xcRunCommand ${findSimulatorsArguments.join(' ')}'; + if (log) { + print('Looking for available simulators...'); + print(findSimulatorCompleteCommand); + } + final io.ProcessResult findSimulatorsResult = + await processRunner.run(_xcRunCommand, findSimulatorsArguments); + if (findSimulatorsResult.exitCode != 0) { + if (log) { + printError( + 'Error occurred while running "$findSimulatorCompleteCommand":\n' + '${findSimulatorsResult.stderr}'); + } + return null; + } + final Map simulatorListJson = + jsonDecode(findSimulatorsResult.stdout as String) + as Map; + final List> runtimes = + (simulatorListJson['runtimes'] as List) + .cast>(); + final Map devices = + (simulatorListJson['devices'] as Map) + .cast(); + if (runtimes.isEmpty || devices.isEmpty) { + return null; + } + String? id; + // Looking for runtimes, trying to find one with highest OS version. + for (final Map rawRuntimeMap in runtimes.reversed) { + final Map runtimeMap = + rawRuntimeMap.cast(); + if ((runtimeMap['name'] as String?)?.contains('iOS') != true) { + continue; + } + final String? runtimeID = runtimeMap['identifier'] as String?; + if (runtimeID == null) { + continue; + } + final List>? devicesForRuntime = + (devices[runtimeID] as List?)?.cast>(); + if (devicesForRuntime == null || devicesForRuntime.isEmpty) { + continue; + } + // Looking for runtimes, trying to find latest version of device. + for (final Map rawDevice in devicesForRuntime.reversed) { + final Map device = rawDevice.cast(); + id = device['udid'] as String?; + if (id == null) { + continue; + } + if (log) { + print('device selected: $device'); + } + return id; + } + } + return null; + } +} diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index f397a04aa663..ef1a18ab15b2 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -24,6 +24,7 @@ import 'publish_plugin_command.dart'; import 'pubspec_check_command.dart'; import 'test_command.dart'; import 'version_check_command.dart'; +import 'xcode_analyze_command.dart'; import 'xctest_command.dart'; void main(List args) { @@ -59,6 +60,7 @@ void main(List args) { ..addCommand(PubspecCheckCommand(packagesDir)) ..addCommand(TestCommand(packagesDir)) ..addCommand(VersionCheckCommand(packagesDir)) + ..addCommand(XcodeAnalyzeCommand(packagesDir)) ..addCommand(XCTestCommand(packagesDir)); commandRunner.run(args).catchError((Object e) { diff --git a/script/tool/lib/src/xcode_analyze_command.dart b/script/tool/lib/src/xcode_analyze_command.dart new file mode 100644 index 000000000000..27cd8c435142 --- /dev/null +++ b/script/tool/lib/src/xcode_analyze_command.dart @@ -0,0 +1,111 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:platform/platform.dart'; + +import 'common/core.dart'; +import 'common/package_looping_command.dart'; +import 'common/plugin_utils.dart'; +import 'common/process_runner.dart'; +import 'common/xcode.dart'; + +/// The command to run Xcode's static analyzer on plugins. +class XcodeAnalyzeCommand extends PackageLoopingCommand { + /// Creates an instance of the test command. + XcodeAnalyzeCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + }) : _xcode = Xcode(processRunner: processRunner, log: true), + super(packagesDir, processRunner: processRunner, platform: platform) { + argParser.addFlag(kPlatformIos, help: 'Analyze iOS'); + argParser.addFlag(kPlatformMacos, help: 'Analyze macOS'); + } + + final Xcode _xcode; + + @override + final String name = 'xcode-analyze'; + + @override + final String description = + 'Runs Xcode analysis on the iOS and/or macOS example apps.'; + + @override + Future initializeRun() async { + if (!(getBoolArg(kPlatformIos) || getBoolArg(kPlatformMacos))) { + printError('At least one platform flag must be provided.'); + throw ToolExit(exitInvalidArguments); + } + } + + @override + Future runForPackage(Directory package) async { + final bool testIos = getBoolArg(kPlatformIos) && + pluginSupportsPlatform(kPlatformIos, package, + requiredMode: PlatformSupport.inline); + final bool testMacos = getBoolArg(kPlatformMacos) && + pluginSupportsPlatform(kPlatformMacos, package, + requiredMode: PlatformSupport.inline); + + final bool multiplePlatformsRequested = + getBoolArg(kPlatformIos) && getBoolArg(kPlatformMacos); + if (!(testIos || testMacos)) { + return PackageResult.skip('Not implemented for target platform(s).'); + } + + final List failures = []; + if (testIos && + !await _analyzePlugin(package, 'iOS', extraFlags: [ + '-destination', + 'generic/platform=iOS Simulator' + ])) { + failures.add('iOS'); + } + if (testMacos && !await _analyzePlugin(package, 'macOS')) { + failures.add('macOS'); + } + + // Only provide the failing platform in the failure details if testing + // multiple platforms, otherwise it's just noise. + return failures.isEmpty + ? PackageResult.success() + : PackageResult.fail( + multiplePlatformsRequested ? failures : []); + } + + /// Analyzes [plugin] for [platform], returning true if it passed analysis. + Future _analyzePlugin( + Directory plugin, + String platform, { + List extraFlags = const [], + }) async { + bool passing = true; + for (final Directory example in getExamplesForPlugin(plugin)) { + // Running tests and static analyzer. + final String examplePath = + getRelativePosixPath(example, from: plugin.parent); + print('Running $platform tests and analyzer for $examplePath...'); + final int exitCode = await _xcode.runXcodeBuild( + example, + actions: ['analyze'], + workspace: '${platform.toLowerCase()}/Runner.xcworkspace', + scheme: 'Runner', + configuration: 'Debug', + extraFlags: [ + ...extraFlags, + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + ); + if (exitCode == 0) { + printSuccess('$examplePath ($platform) passed analysis.'); + } else { + printError('$examplePath ($platform) failed analysis.'); + passing = false; + } + } + return passing; + } +} diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart index 176adad39a09..44fc3a87d540 100644 --- a/script/tool/lib/src/xctest_command.dart +++ b/script/tool/lib/src/xctest_command.dart @@ -2,9 +2,6 @@ // 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:platform/platform.dart'; @@ -12,35 +9,39 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +import 'common/xcode.dart'; + +const String _iosDestinationFlag = 'ios-destination'; +const String _testTargetFlag = 'test-target'; -const String _kiOSDestination = 'ios-destination'; -const String _kXcodeBuildCommand = 'xcodebuild'; -const String _kXCRunCommand = 'xcrun'; -const String _kFoundNoSimulatorsMessage = - 'Cannot find any available simulators, tests failed'; +// The exit code from 'xcodebuild test' when there are no tests. +const int _xcodebuildNoTestExitCode = 66; -const int _exitFindingSimulatorsFailed = 3; -const int _exitNoSimulators = 4; +const int _exitNoSimulators = 3; /// The command to run XCTests (XCUnitTest and XCUITest) in plugins. /// The tests target have to be added to the Xcode project of the example app, /// usually at "example/{ios,macos}/Runner.xcworkspace". -/// -/// The static analyzer is also run. class XCTestCommand extends PackageLoopingCommand { /// Creates an instance of the test command. XCTestCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), - }) : super(packagesDir, processRunner: processRunner, platform: platform) { + }) : _xcode = Xcode(processRunner: processRunner, log: true), + super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addOption( - _kiOSDestination, + _iosDestinationFlag, help: 'Specify the destination when running the test, used for -destination flag for xcodebuild command.\n' 'this is passed to the `-destination` argument in xcodebuild command.\n' 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.', ); + argParser.addOption( + _testTargetFlag, + help: + 'Limits the tests to a specific target (e.g., RunnerTests or RunnerUITests)', + ); argParser.addFlag(kPlatformIos, help: 'Runs the iOS tests'); argParser.addFlag(kPlatformMacos, help: 'Runs the macOS tests'); } @@ -48,6 +49,8 @@ class XCTestCommand extends PackageLoopingCommand { // The device destination flags for iOS tests. List _iosDestinationFlags = []; + final Xcode _xcode; + @override final String name = 'xctest'; @@ -56,9 +59,6 @@ class XCTestCommand extends PackageLoopingCommand { 'Runs the xctests in the iOS and/or macOS example apps.\n\n' 'This command requires "flutter" and "xcrun" to be in your path.'; - @override - String get failureListHeader => 'The following packages are failing XCTests:'; - @override Future initializeRun() async { final bool shouldTestIos = getBoolArg(kPlatformIos); @@ -70,11 +70,12 @@ class XCTestCommand extends PackageLoopingCommand { } if (shouldTestIos) { - String destination = getStringArg(_kiOSDestination); + String destination = getStringArg(_iosDestinationFlag); if (destination.isEmpty) { - final String? simulatorId = await _findAvailableIphoneSimulator(); + final String? simulatorId = + await _xcode.findBestAvailableIphoneSimulator(); if (simulatorId == null) { - printError(_kFoundNoSimulatorsMessage); + printError('Cannot find any available simulators, tests failed'); throw ToolExit(_exitNoSimulators); } destination = 'id=$simulatorId'; @@ -115,15 +116,26 @@ class XCTestCommand extends PackageLoopingCommand { } final List failures = []; - if (testIos && - !await _testPlugin(package, 'iOS', - extraXcrunFlags: _iosDestinationFlags)) { - failures.add('iOS'); + bool ranTests = false; + if (testIos) { + final RunState result = await _testPlugin(package, 'iOS', + extraXcrunFlags: _iosDestinationFlags); + ranTests |= result != RunState.skipped; + if (result == RunState.failed) { + failures.add('iOS'); + } } - if (testMacos && !await _testPlugin(package, 'macOS')) { - failures.add('macOS'); + if (testMacos) { + final RunState result = await _testPlugin(package, 'macOS'); + ranTests |= result != RunState.skipped; + if (result == RunState.failed) { + failures.add('macOS'); + } } + if (!ranTests) { + return PackageResult.skip('No tests found.'); + } // Only provide the failing platform in the failure details if testing // multiple platforms, otherwise it's just noise. return failures.isEmpty @@ -133,124 +145,67 @@ class XCTestCommand extends PackageLoopingCommand { } /// Runs all applicable tests for [plugin], printing status and returning - /// success if the tests passed. - Future _testPlugin( + /// the test result. + Future _testPlugin( Directory plugin, String platform, { List extraXcrunFlags = const [], }) async { - bool passing = true; + final String testTarget = getStringArg(_testTargetFlag); + + // Assume skipped until at least one test has run. + RunState overallResult = RunState.skipped; for (final Directory example in getExamplesForPlugin(plugin)) { - // Running tests and static analyzer. final String examplePath = getRelativePosixPath(example, from: plugin.parent); - print('Running $platform tests and analyzer for $examplePath...'); - int exitCode = - await _runTests(true, example, platform, extraFlags: extraXcrunFlags); - // 66 = there is no test target (this fails fast). Try again with just the analyzer. - if (exitCode == 66) { - print('Tests not found for $examplePath, running analyzer only...'); - exitCode = await _runTests(false, example, platform, - extraFlags: extraXcrunFlags); - } - if (exitCode == 0) { - printSuccess('Successfully ran $platform xctest for $examplePath'); - } else { - passing = false; - } - } - return passing; - } - Future _runTests( - bool runTests, - Directory example, - String platform, { - List extraFlags = const [], - }) { - final List xctestArgs = [ - _kXcodeBuildCommand, - if (runTests) 'test', - 'analyze', - '-workspace', - '${platform.toLowerCase()}/Runner.xcworkspace', - '-configuration', - 'Debug', - '-scheme', - 'Runner', - ...extraFlags, - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ]; - final String completeTestCommand = - '$_kXCRunCommand ${xctestArgs.join(' ')}'; - print(completeTestCommand); - return processRunner.runAndStream(_kXCRunCommand, xctestArgs, - workingDir: example); - } - - Future _findAvailableIphoneSimulator() async { - // Find the first available destination if not specified. - final List findSimulatorsArguments = [ - 'simctl', - 'list', - '--json' - ]; - final String findSimulatorCompleteCommand = - '$_kXCRunCommand ${findSimulatorsArguments.join(' ')}'; - print('Looking for available simulators...'); - print(findSimulatorCompleteCommand); - final io.ProcessResult findSimulatorsResult = - await processRunner.run(_kXCRunCommand, findSimulatorsArguments); - if (findSimulatorsResult.exitCode != 0) { - printError( - 'Error occurred while running "$findSimulatorCompleteCommand":\n' - '${findSimulatorsResult.stderr}'); - throw ToolExit(_exitFindingSimulatorsFailed); - } - final Map simulatorListJson = - jsonDecode(findSimulatorsResult.stdout as String) - as Map; - final List> runtimes = - (simulatorListJson['runtimes'] as List) - .cast>(); - final Map devices = - (simulatorListJson['devices'] as Map) - .cast(); - if (runtimes.isEmpty || devices.isEmpty) { - return null; - } - String? id; - // Looking for runtimes, trying to find one with highest OS version. - for (final Map rawRuntimeMap in runtimes.reversed) { - final Map runtimeMap = - rawRuntimeMap.cast(); - if ((runtimeMap['name'] as String?)?.contains('iOS') != true) { - continue; - } - final String? runtimeID = runtimeMap['identifier'] as String?; - if (runtimeID == null) { - continue; - } - final List>? devicesForRuntime = - (devices[runtimeID] as List?)?.cast>(); - if (devicesForRuntime == null || devicesForRuntime.isEmpty) { - continue; - } - // Looking for runtimes, trying to find latest version of device. - for (final Map rawDevice in devicesForRuntime.reversed) { - final Map device = rawDevice.cast(); - if (device['availabilityError'] != null || - (device['isAvailable'] as bool?) == false) { + if (testTarget.isNotEmpty) { + final Directory project = example + .childDirectory(platform.toLowerCase()) + .childDirectory('Runner.xcodeproj'); + final bool? hasTarget = + await _xcode.projectHasTarget(project, testTarget); + if (hasTarget == null) { + printError('Unable to check targets for $examplePath.'); + overallResult = RunState.failed; continue; - } - id = device['udid'] as String?; - if (id == null) { + } else if (!hasTarget) { + print('No "$testTarget" target in $examplePath; skipping.'); continue; } - print('device selected: $device'); - return id; + } + + print('Running $platform tests for $examplePath...'); + final int exitCode = await _xcode.runXcodeBuild( + example, + actions: ['test'], + workspace: '${platform.toLowerCase()}/Runner.xcworkspace', + scheme: 'Runner', + configuration: 'Debug', + extraFlags: [ + if (testTarget.isNotEmpty) '-only-testing:$testTarget', + ...extraXcrunFlags, + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + ); + + switch (exitCode) { + case _xcodebuildNoTestExitCode: + print('No tests found for $examplePath'); + continue; + case 0: + printSuccess('Successfully ran $platform xctest for $examplePath'); + // If this is the first test, assume success until something fails. + if (overallResult == RunState.skipped) { + overallResult = RunState.succeeded; + } + break; + default: + // Any failure means a failure overall. + overallResult = RunState.failed; + break; } } - return null; + return overallResult; } } diff --git a/script/tool/test/common/xcode_test.dart b/script/tool/test/common/xcode_test.dart new file mode 100644 index 000000000000..7e046a2446c2 --- /dev/null +++ b/script/tool/test/common/xcode_test.dart @@ -0,0 +1,396 @@ +// Copyright 2013 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:file/local.dart'; +import 'package:flutter_plugin_tools/src/common/xcode.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import '../util.dart'; + +void main() { + late RecordingProcessRunner processRunner; + late Xcode xcode; + + setUp(() { + processRunner = RecordingProcessRunner(); + xcode = Xcode(processRunner: processRunner); + }); + + group('findBestAvailableIphoneSimulator', () { + test('finds the newest device', () async { + const String expectedDeviceId = '1E76A0FD-38AC-4537-A989-EA639D7D012A'; + // Note: This uses `dynamic` deliberately, and should not be updated to + // Object, in order to ensure that the code correctly handles this return + // type from JSON decoding. + final Map devices = { + 'runtimes': >[ + { + 'bundlePath': + '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.0.simruntime', + 'buildversion': '17A577', + 'runtimeRoot': + '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.0.simruntime/Contents/Resources/RuntimeRoot', + 'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-13-0', + 'version': '13.0', + 'isAvailable': true, + 'name': 'iOS 13.0' + }, + { + 'bundlePath': + '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime', + 'buildversion': '17L255', + 'runtimeRoot': + '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime/Contents/Resources/RuntimeRoot', + 'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-13-4', + 'version': '13.4', + 'isAvailable': true, + 'name': 'iOS 13.4' + }, + { + 'bundlePath': + '/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime', + 'buildversion': '17T531', + 'runtimeRoot': + '/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot', + 'identifier': 'com.apple.CoreSimulator.SimRuntime.watchOS-6-2', + 'version': '6.2.1', + 'isAvailable': true, + 'name': 'watchOS 6.2' + } + ], + 'devices': { + 'com.apple.CoreSimulator.SimRuntime.iOS-13-4': >[ + { + 'dataPath': + '/Users/xxx/Library/Developer/CoreSimulator/Devices/2706BBEB-1E01-403E-A8E9-70E8E5A24774/data', + 'logPath': + '/Users/xxx/Library/Logs/CoreSimulator/2706BBEB-1E01-403E-A8E9-70E8E5A24774', + 'udid': '2706BBEB-1E01-403E-A8E9-70E8E5A24774', + 'isAvailable': true, + 'deviceTypeIdentifier': + 'com.apple.CoreSimulator.SimDeviceType.iPhone-8', + 'state': 'Shutdown', + 'name': 'iPhone 8' + }, + { + 'dataPath': + '/Users/xxx/Library/Developer/CoreSimulator/Devices/1E76A0FD-38AC-4537-A989-EA639D7D012A/data', + 'logPath': + '/Users/xxx/Library/Logs/CoreSimulator/1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'udid': expectedDeviceId, + 'isAvailable': true, + 'deviceTypeIdentifier': + 'com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus', + 'state': 'Shutdown', + 'name': 'iPhone 8 Plus' + } + ] + } + }; + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = jsonEncode(devices); + + expect(await xcode.findBestAvailableIphoneSimulator(), expectedDeviceId); + }); + + test('ignores non-iOS runtimes', () async { + // Note: This uses `dynamic` deliberately, and should not be updated to + // Object, in order to ensure that the code correctly handles this return + // type from JSON decoding. + final Map devices = { + 'runtimes': >[ + { + 'bundlePath': + '/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime', + 'buildversion': '17T531', + 'runtimeRoot': + '/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot', + 'identifier': 'com.apple.CoreSimulator.SimRuntime.watchOS-6-2', + 'version': '6.2.1', + 'isAvailable': true, + 'name': 'watchOS 6.2' + } + ], + 'devices': { + 'com.apple.CoreSimulator.SimRuntime.watchOS-6-2': + >[ + { + 'dataPath': + '/Users/xxx/Library/Developer/CoreSimulator/Devices/1E76A0FD-38AC-4537-A989-EA639D7D012A/data', + 'logPath': + '/Users/xxx/Library/Logs/CoreSimulator/1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'udid': '1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'isAvailable': true, + 'deviceTypeIdentifier': + 'com.apple.CoreSimulator.SimDeviceType.Apple-Watch-38mm', + 'state': 'Shutdown', + 'name': 'Apple Watch' + } + ] + } + }; + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = jsonEncode(devices); + + expect(await xcode.findBestAvailableIphoneSimulator(), null); + }); + + test('returns null if simctl fails', () async { + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing(), + ]; + + expect(await xcode.findBestAvailableIphoneSimulator(), null); + }); + }); + + group('runXcodeBuild', () { + test('handles minimal arguments', () async { + final Directory directory = const LocalFileSystem().currentDirectory; + + final int exitCode = await xcode.runXcodeBuild( + directory, + workspace: 'A.xcworkspace', + scheme: 'AScheme', + ); + + expect(exitCode, 0); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'build', + '-workspace', + 'A.xcworkspace', + '-scheme', + 'AScheme', + ], + directory.path), + ])); + }); + + test('handles all arguments', () async { + final Directory directory = const LocalFileSystem().currentDirectory; + + final int exitCode = await xcode.runXcodeBuild(directory, + actions: ['action1', 'action2'], + workspace: 'A.xcworkspace', + scheme: 'AScheme', + configuration: 'Debug', + extraFlags: ['-a', '-b', 'c=d']); + + expect(exitCode, 0); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'action1', + 'action2', + '-workspace', + 'A.xcworkspace', + '-scheme', + 'AScheme', + '-configuration', + 'Debug', + '-a', + '-b', + 'c=d', + ], + directory.path), + ])); + }); + + test('returns error codes', () async { + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing(), + ]; + final Directory directory = const LocalFileSystem().currentDirectory; + + final int exitCode = await xcode.runXcodeBuild( + directory, + workspace: 'A.xcworkspace', + scheme: 'AScheme', + ); + + expect(exitCode, 1); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'build', + '-workspace', + 'A.xcworkspace', + '-scheme', + 'AScheme', + ], + directory.path), + ])); + }); + }); + + group('projectHasTarget', () { + test('returns true when present', () async { + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = ''' +{ + "project" : { + "configurations" : [ + "Debug", + "Release" + ], + "name" : "Runner", + "schemes" : [ + "Runner" + ], + "targets" : [ + "Runner", + "RunnerTests", + "RunnerUITests" + ] + } +}'''; + + final Directory project = + const LocalFileSystem().directory('/foo.xcodeproj'); + expect(await xcode.projectHasTarget(project, 'RunnerTests'), true); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + project.path, + ], + null), + ])); + }); + + test('returns false when not present', () async { + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = ''' +{ + "project" : { + "configurations" : [ + "Debug", + "Release" + ], + "name" : "Runner", + "schemes" : [ + "Runner" + ], + "targets" : [ + "Runner", + "RunnerUITests" + ] + } +}'''; + + final Directory project = + const LocalFileSystem().directory('/foo.xcodeproj'); + expect(await xcode.projectHasTarget(project, 'RunnerTests'), false); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + project.path, + ], + null), + ])); + }); + + test('returns null for unexpected output', () async { + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = '{}'; + + final Directory project = + const LocalFileSystem().directory('/foo.xcodeproj'); + expect(await xcode.projectHasTarget(project, 'RunnerTests'), null); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + project.path, + ], + null), + ])); + }); + + test('returns null for invalid output', () async { + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = ':)'; + + final Directory project = + const LocalFileSystem().directory('/foo.xcodeproj'); + expect(await xcode.projectHasTarget(project, 'RunnerTests'), null); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + project.path, + ], + null), + ])); + }); + + test('returns null for failure', () async { + processRunner.processToReturn = MockProcess.failing(); + + final Directory project = + const LocalFileSystem().directory('/foo.xcodeproj'); + expect(await xcode.projectHasTarget(project, 'RunnerTests'), null); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + project.path, + ], + null), + ])); + }); + }); +} diff --git a/script/tool/test/xcode_analyze_command_test.dart b/script/tool/test/xcode_analyze_command_test.dart new file mode 100644 index 000000000000..b715ac531f50 --- /dev/null +++ b/script/tool/test/xcode_analyze_command_test.dart @@ -0,0 +1,416 @@ +// Copyright 2013 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:io' as io; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:flutter_plugin_tools/src/xcode_analyze_command.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +// TODO(stuartmorgan): Rework these tests to use a mock Xcode instead of +// doing all the process mocking and validation. +void main() { + group('test xcode_analyze_command', () { + late FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(isMacOS: true); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); + final XcodeAnalyzeCommand command = XcodeAnalyzeCommand(packagesDir, + processRunner: processRunner, platform: mockPlatform); + + runner = CommandRunner( + 'xcode_analyze_command', 'Test for xcode_analyze_command'); + runner.addCommand(command); + }); + + test('Fails if no platforms are provided', () async { + Error? commandError; + final List output = await runCapturingPrint( + runner, ['xcode-analyze'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('At least one platform flag must be provided'), + ]), + ); + }); + + group('iOS', () { + test('skip if iOS is not supported', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final List output = + await runCapturingPrint(runner, ['xcode-analyze', '--ios']); + expect(output, + contains(contains('Not implemented for target platform(s).'))); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if iOS is implemented in a federated package', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.federated + }); + + final List output = + await runCapturingPrint(runner, ['xcode-analyze', '--ios']); + expect(output, + contains(contains('Not implemented for target platform(s).'))); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('runs for iOS plugin', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'xcode-analyze', + '--ios', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('plugin/example (iOS) passed analysis.') + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'generic/platform=iOS Simulator', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('fails if xcrun fails', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'xcode-analyze', + '--ios', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages had errors:'), + contains(' plugin'), + ])); + }); + }); + + group('macOS', () { + test('skip if macOS is not supported', () async { + createFakePlugin( + 'plugin', + packagesDir, + ); + + final List output = await runCapturingPrint( + runner, ['xcode-analyze', '--macos']); + expect(output, + contains(contains('Not implemented for target platform(s).'))); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if macOS is implemented in a federated package', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.federated, + }); + + final List output = await runCapturingPrint( + runner, ['xcode-analyze', '--macos']); + expect(output, + contains(contains('Not implemented for target platform(s).'))); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('runs for macOS plugin', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'xcode-analyze', + '--macos', + ]); + + expect(output, + contains(contains('plugin/example (macOS) passed analysis.'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'analyze', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('fails if xcrun fails', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['xcode-analyze', '--macos'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages had errors:'), + contains(' plugin'), + ]), + ); + }); + }); + + group('combined', () { + test('runs both iOS and macOS when supported', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline, + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'xcode-analyze', + '--ios', + '--macos', + ]); + + expect( + output, + containsAll([ + contains('plugin/example (iOS) passed analysis.'), + contains('plugin/example (macOS) passed analysis.'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'generic/platform=iOS Simulator', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'analyze', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('runs only macOS for a macOS plugin', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'xcode-analyze', + '--ios', + '--macos', + ]); + + expect( + output, + containsAllInOrder([ + contains('plugin/example (macOS) passed analysis.'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'analyze', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('runs only iOS for a iOS plugin', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'xcode-analyze', + '--ios', + '--macos', + ]); + + expect( + output, + containsAllInOrder( + [contains('plugin/example (iOS) passed analysis.')])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'analyze', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'generic/platform=iOS Simulator', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('skips when neither are supported', () async { + createFakePlugin('plugin', packagesDir); + + final List output = await runCapturingPrint(runner, [ + 'xcode-analyze', + '--ios', + '--macos', + ]); + + expect( + output, + containsAllInOrder([ + contains('SKIPPING: Not implemented for target platform(s).'), + ])); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + }); + }); +} diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart index aa6d23fb56f5..324dea0e71ef 100644 --- a/script/tool/test/xctest_command_test.dart +++ b/script/tool/test/xctest_command_test.dart @@ -16,22 +16,8 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'util.dart'; -// Note: This uses `dynamic` deliberately, and should not be updated to Object, -// in order to ensure that the code correctly handles this return type from -// JSON decoding. final Map _kDeviceListMap = { 'runtimes': >[ - { - 'bundlePath': - '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.0.simruntime', - 'buildversion': '17A577', - 'runtimeRoot': - '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.0.simruntime/Contents/Resources/RuntimeRoot', - 'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-13-0', - 'version': '13.0', - 'isAvailable': true, - 'name': 'iOS 13.0' - }, { 'bundlePath': '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime', @@ -43,32 +29,9 @@ final Map _kDeviceListMap = { 'isAvailable': true, 'name': 'iOS 13.4' }, - { - 'bundlePath': - '/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime', - 'buildversion': '17T531', - 'runtimeRoot': - '/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot', - 'identifier': 'com.apple.CoreSimulator.SimRuntime.watchOS-6-2', - 'version': '6.2.1', - 'isAvailable': true, - 'name': 'watchOS 6.2' - } ], 'devices': { 'com.apple.CoreSimulator.SimRuntime.iOS-13-4': >[ - { - 'dataPath': - '/Users/xxx/Library/Developer/CoreSimulator/Devices/2706BBEB-1E01-403E-A8E9-70E8E5A24774/data', - 'logPath': - '/Users/xxx/Library/Logs/CoreSimulator/2706BBEB-1E01-403E-A8E9-70E8E5A24774', - 'udid': '2706BBEB-1E01-403E-A8E9-70E8E5A24774', - 'isAvailable': true, - 'deviceTypeIdentifier': - 'com.apple.CoreSimulator.SimDeviceType.iPhone-8', - 'state': 'Shutdown', - 'name': 'iPhone 8' - }, { 'dataPath': '/Users/xxx/Library/Developer/CoreSimulator/Devices/1E76A0FD-38AC-4537-A989-EA639D7D012A/data', @@ -85,6 +48,8 @@ final Map _kDeviceListMap = { } }; +// TODO(stuartmorgan): Rework these tests to use a mock Xcode instead of +// doing all the process mocking and validation. void main() { const String _kDestination = '--ios-destination'; @@ -123,13 +88,198 @@ void main() { ); }); + test('allows target filtering', () async { + final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = '{"project":{"targets":["RunnerTests"]}}'; + + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--macos', + '--test-target=RunnerTests', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-only-testing:RunnerTests', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('skips when the requested target is not present', () async { + final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = '{"project":{"targets":["Runner"]}}'; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--macos', + '--test-target=RunnerTests', + ]); + + expect( + output, + containsAllInOrder([ + contains('No "RunnerTests" target in plugin/example; skipping.'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ])); + }); + + test('fails if unable to check for requested target', () async { + final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.failing(); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'xctest', + '--macos', + '--test-target=RunnerTests', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to check targets for plugin/example.'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ])); + }); + + test('reports skips with no tests', () async { + final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + // Exit code 66 from testing indicates no tests. + final MockProcess noTestsProcessResult = MockProcess(); + noTestsProcessResult.exitCodeCompleter.complete(66); + processRunner.mockProcessesForExecutable['xcrun'] = [ + noTestsProcessResult, + ]; + final List output = + await runCapturingPrint(runner, ['xctest', '--macos']); + + expect(output, contains(contains('No tests found.'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + group('iOS', () { test('skip if iOS is not supported', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final List output = await runCapturingPrint(runner, ['xctest', '--ios', _kDestination, 'foo_destination']); @@ -141,11 +291,10 @@ void main() { }); test('skip if iOS is implemented in a federated package', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformIos: PlatformSupport.federated - }); + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.federated + }); final List output = await runCapturingPrint(runner, ['xctest', '--ios', _kDestination, 'foo_destination']); @@ -157,19 +306,14 @@ void main() { }); test('running with correct destination', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { kPlatformIos: PlatformSupport.inline }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ 'xctest', '--ios', @@ -192,13 +336,12 @@ void main() { const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', '-destination', 'foo_destination', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', @@ -209,45 +352,43 @@ void main() { test('Not specifying --ios-destination assigns an available simulator', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { kPlatformIos: PlatformSupport.inline }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - final Map schemeCommandResult = { - 'project': { - 'targets': ['bar_scheme', 'foo_scheme'] - } - }; processRunner.processToReturn = MockProcess.succeeding(); - // For simplicity of the test, we combine all the mock results into a single mock result, each internal command - // will get this result and they should still be able to parse them correctly. - processRunner.resultStdout = - jsonEncode(schemeCommandResult..addAll(_kDeviceListMap)); + processRunner.resultStdout = jsonEncode(_kDeviceListMap); await runCapturingPrint(runner, ['xctest', '--ios']); expect( processRunner.recordedCalls, orderedEquals([ const ProcessCall( - 'xcrun', ['simctl', 'list', '--json'], null), + 'xcrun', + [ + 'simctl', + 'list', + 'devices', + 'runtimes', + 'available', + '--json', + ], + null), ProcessCall( 'xcrun', const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', '-destination', 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', @@ -257,15 +398,11 @@ void main() { }); test('fails if xcrun fails', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformIos: PlatformSupport.inline - }); + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline + }); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; processRunner.mockProcessesForExecutable['xcrun'] = [ MockProcess.failing() ]; @@ -288,7 +425,7 @@ void main() { expect( output, containsAllInOrder([ - contains('The following packages are failing XCTests:'), + contains('The following packages had errors:'), contains(' plugin'), ])); }); @@ -296,16 +433,10 @@ void main() { group('macOS', () { test('skip if macOS is not supported', () async { - createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - ); + createFakePlugin('plugin', packagesDir); - final List output = await runCapturingPrint(runner, - ['xctest', '--macos', _kDestination, 'foo_destination']); + final List output = + await runCapturingPrint(runner, ['xctest', '--macos']); expect( output, contains( @@ -314,14 +445,13 @@ void main() { }); test('skip if macOS is implemented in a federated package', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformMacos: PlatformSupport.federated, - }); + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.federated, + }); - final List output = await runCapturingPrint(runner, - ['xctest', '--macos', _kDestination, 'foo_destination']); + final List output = + await runCapturingPrint(runner, ['xctest', '--macos']); expect( output, contains( @@ -330,19 +460,15 @@ void main() { }); test('runs for macOS plugin', () async { - final Directory pluginDirectory1 = - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ 'xctest', '--macos', @@ -361,13 +487,12 @@ void main() { const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'macos/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', ], pluginExampleDirectory.path), @@ -375,15 +500,11 @@ void main() { }); test('fails if xcrun fails', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; processRunner.mockProcessesForExecutable['xcrun'] = [ MockProcess.failing() ]; @@ -398,7 +519,7 @@ void main() { expect( output, containsAllInOrder([ - contains('The following packages are failing XCTests:'), + contains('The following packages had errors:'), contains(' plugin'), ]), ); @@ -407,20 +528,16 @@ void main() { group('combined', () { test('runs both iOS and macOS when supported', () async { - final Directory pluginDirectory1 = - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformIos: PlatformSupport.inline, - kPlatformMacos: PlatformSupport.inline, - }); + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.inline, + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ 'xctest', '--ios', @@ -444,13 +561,12 @@ void main() { const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', '-destination', 'foo_destination', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', @@ -461,13 +577,12 @@ void main() { const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'macos/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', ], pluginExampleDirectory.path), @@ -475,19 +590,15 @@ void main() { }); test('runs only macOS for a macOS plugin', () async { - final Directory pluginDirectory1 = - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ 'xctest', '--ios', @@ -511,13 +622,12 @@ void main() { const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'macos/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', ], pluginExampleDirectory.path), @@ -525,19 +635,14 @@ void main() { }); test('runs only iOS for a iOS plugin', () async { - final Directory pluginDirectory = - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], platformSupport: { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { kPlatformIos: PlatformSupport.inline }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ 'xctest', '--ios', @@ -561,13 +666,12 @@ void main() { const [ 'xcodebuild', 'test', - 'analyze', '-workspace', 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', '-scheme', 'Runner', + '-configuration', + 'Debug', '-destination', 'foo_destination', 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', @@ -577,13 +681,8 @@ void main() { }); test('skips when neither are supported', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ]); + createFakePlugin('plugin', packagesDir); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["bar_scheme", "foo_scheme"]}}'; final List output = await runCapturingPrint(runner, [ 'xctest', '--ios', From 44d7c072dfdb0aa83600606c7da9f806cefd0bd8 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 21 Jul 2021 12:03:47 -0700 Subject: [PATCH 144/364] [flutter_plugin_tools] Make firebase-test-lab fail when no tests run (#4172) If a package supports Android, it will now report failure instead of skip if no tests run. This matches the new behavior of drive-examples, and is intended to prevent recurrance of situations where we are silently failing to run tests because of, e.g., tests being in the wrong directory. Also fixes a long-standing but unnoticed problem where if a run tried to run more than one package's tests, it would hang forever (although on the bots it doesn't seem to time out, just end logs abruptly) due to a logic error in the call to configure gcloud. Fixes flutter/flutter#86732 --- .cirrus.yml | 18 +- script/tool/CHANGELOG.md | 6 + .../lib/src/firebase_test_lab_command.dart | 36 ++-- .../test/firebase_test_lab_command_test.dart | 160 +++++++++++++++++- 4 files changed, 202 insertions(+), 18 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index bf5675b6e3ae..96902cfd6d15 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -137,6 +137,22 @@ task: CHANNEL: "stable" MAPS_API_KEY: ENCRYPTED[596a9f6bca436694625ac50851dc5da6b4d34cba8025f7db5bc9465142e8cd44e15f69e3507787753accebfc4910d550] GCLOUD_FIREBASE_TESTLAB_KEY: ENCRYPTED[07586610af1fdfc894e5969f70ef2458341b9b7e9c3b7c4225a663b4a48732b7208a4d91c3b7d45305a6b55fa2a37fc4] + # Currently missing harness files (https://github.com/flutter/flutter/issues/86749): + # camera/camera + # google_sign_in/google_sign_in + # in_app_purchase/in_app_purchase + # in_app_purchase_android + # quick_actions + # shared_preferences/shared_preferences + # url_launcher/url_launcher + # video_player/video_player + # webview_flutter + # Deprecated; no plan to backfill the missing files: + # android_intent,connectivity/connectivity,device_info/device_info,sensors,share,wifi_info_flutter/wifi_info_flutter + # No integration tests to run: + # image_picker/image_picker - Native UI is the critical functionality + # espresso - No Dart code, so no integration tests + PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "camera/camera,google_sign_in/google_sign_in,in_app_purchase/in_app_purchase,in_app_purchase_android,quick_actions,shared_preferences/shared_preferences,url_launcher/url_launcher,video_player/video_player,webview_flutter,android_intent,connectivity/connectivity,device_info/device_info,sensors,share,wifi_info_flutter/wifi_info_flutter,image_picker/image_picker,espresso" build_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. @@ -159,7 +175,7 @@ task: - export CIRRUS_COMMIT_MESSAGE="" - if [[ -n "$GCLOUD_FIREBASE_TESTLAB_KEY" ]]; then - echo $GCLOUD_FIREBASE_TESTLAB_KEY > ${HOME}/gcloud-service-key.json - - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 + - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS - else - echo "This user does not have permission to run Firebase Test Lab tests." - fi diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 377e7860bd26..d701278ee76f 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -3,6 +3,12 @@ - Added an `xctest` flag to select specific test targets, to allow running only unit tests or integration tests. - Split Xcode analysis out of `xctest` and into a new `xcode-analyze` command. +- Fixed a bug that caused `firebase-test-lab` to hang if it tried to run more + than one plugin's tests in a single run. +- **Breaking change**: If `firebase-test-lab` is run on a package that supports + Android, but for which no tests are run, it now fails instead of skipping. + This matches `drive-examples`, as this command is what is used for driving + Android Flutter integration tests on CI. ## 0.4.1 diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 5e4d9f080085..304912824960 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -76,13 +76,12 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { static const String _gradleWrapper = 'gradlew'; - Completer? _firebaseProjectConfigured; + bool _firebaseProjectConfigured = false; Future _configureFirebaseProject() async { - if (_firebaseProjectConfigured != null) { - return _firebaseProjectConfigured!.future; + if (_firebaseProjectConfigured) { + return; } - _firebaseProjectConfigured = Completer(); final String serviceKey = getStringArg('service-key'); if (serviceKey.isEmpty) { @@ -110,31 +109,34 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { print(''); if (exitCode == 0) { print('Firebase project configured.'); - return; } else { logWarning( 'Warning: gcloud config set returned a non-zero exit code. Continuing anyway.'); } } - _firebaseProjectConfigured!.complete(null); + _firebaseProjectConfigured = true; } @override Future runForPackage(Directory package) async { - if (!package - .childDirectory('example') - .childDirectory('android') + final Directory exampleDirectory = package.childDirectory('example'); + final Directory androidDirectory = + exampleDirectory.childDirectory('android'); + if (!androidDirectory.existsSync()) { + return PackageResult.skip( + '${getPackageDescription(exampleDirectory)} does not support Android.'); + } + + if (!androidDirectory .childDirectory('app') .childDirectory('src') .childDirectory('androidTest') .existsSync()) { - return PackageResult.skip('No example with androidTest directory'); + printError('No androidTest directory found.'); + return PackageResult.fail( + ['No tests ran (use --exclude if this is intentional).']); } - final Directory exampleDirectory = package.childDirectory('example'); - final Directory androidDirectory = - exampleDirectory.childDirectory('android'); - // Ensures that gradle wrapper exists if (!await _ensureGradleWrapperExists(androidDirectory)) { return PackageResult.fail(['Unable to build example apk']); @@ -191,6 +193,12 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { errors.add('$testName failed tests'); } } + + if (errors.isEmpty && resultsCounter == 0) { + printError('No integration tests were run.'); + errors.add('No tests ran (use --exclude if this is intentional).'); + } + return errors.isEmpty ? PackageResult.success() : PackageResult.fail(errors); diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index c265868bbf3e..185b9d83f0fe 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -84,6 +84,85 @@ void main() { ])); }); + test('only runs gcloud configuration once', () async { + createFakePlugin('plugin1', packagesDir, extraFiles: [ + 'test/plugin_test.dart', + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + createFakePlugin('plugin2', packagesDir, extraFiles: [ + 'test/plugin_test.dart', + 'example/integration_test/bar_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final List output = await runCapturingPrint(runner, [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin1'), + contains('Firebase project configured.'), + contains('Testing example/integration_test/foo_test.dart...'), + contains('Running for plugin2'), + contains('Testing example/integration_test/bar_test.dart...'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'gcloud', + 'auth activate-service-account --key-file=${Platform.environment['HOME']}/gcloud-service-key.json' + .split(' '), + null), + ProcessCall( + 'gcloud', 'config set project flutter-infra'.split(' '), null), + ProcessCall( + '/packages/plugin1/example/android/gradlew', + 'app:assembleAndroidTest -Pverbose=true'.split(' '), + '/packages/plugin1/example/android'), + ProcessCall( + '/packages/plugin1/example/android/gradlew', + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin1/example/integration_test/foo_test.dart' + .split(' '), + '/packages/plugin1/example/android'), + ProcessCall( + 'gcloud', + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin1/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + .split(' '), + '/packages/plugin1/example'), + ProcessCall( + '/packages/plugin2/example/android/gradlew', + 'app:assembleAndroidTest -Pverbose=true'.split(' '), + '/packages/plugin2/example/android'), + ProcessCall( + '/packages/plugin2/example/android/gradlew', + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin2/example/integration_test/bar_test.dart' + .split(' '), + '/packages/plugin2/example/android'), + ProcessCall( + 'gcloud', + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin2/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + .split(' '), + '/packages/plugin2/example'), + ]), + ); + }); + test('runs integration tests', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ 'test/plugin_test.dart', @@ -203,12 +282,87 @@ void main() { ); }); - test('skips packages with no androidTest directory', () async { + test('fails for packages with no androidTest directory', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', 'example/android/gradlew', ]); + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No androidTest directory found.'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' No tests ran (use --exclude if this is intentional).'), + ]), + ); + }); + + test('fails for packages with no integration test files', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=flame,version=29', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('No integration tests were run'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' No tests ran (use --exclude if this is intentional).'), + ]), + ); + }); + + test('skips packages with no android directory', () async { + createFakePackage('package', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + ]); + final List output = await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', @@ -224,8 +378,8 @@ void main() { expect( output, containsAllInOrder([ - contains('Running for plugin'), - contains('No example with androidTest directory'), + contains('Running for package'), + contains('package/example does not support Android'), ]), ); expect(output, From fdded21a2145dd315d80145b1f6f2be5c8e8db49 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 22 Jul 2021 02:41:05 +0200 Subject: [PATCH 145/364] [camera_web] Add `availableCameras` implementation (#4175) --- .../camera_settings_test.dart | 210 +++++++++++++++ .../integration_test/camera_web_test.dart | 255 +++++++++++++++++- .../integration_test/helpers/mocks.dart | 41 ++- .../camera_web/lib/src/camera_settings.dart | 108 ++++++++ .../camera/camera_web/lib/src/camera_web.dart | 119 +++++++- .../lib/src/types/camera_metadata.dart | 37 +++ .../lib/src/types/media_device_kind.dart | 17 ++ .../camera_web/lib/src/types/types.dart | 2 + .../test/types/camera_metadata_test.dart | 25 ++ 9 files changed, 798 insertions(+), 16 deletions(-) create mode 100644 packages/camera/camera_web/example/integration_test/camera_settings_test.dart create mode 100644 packages/camera/camera_web/lib/src/camera_settings.dart create mode 100644 packages/camera/camera_web/lib/src/types/camera_metadata.dart create mode 100644 packages/camera/camera_web/lib/src/types/media_device_kind.dart create mode 100644 packages/camera/camera_web/test/types/camera_metadata_test.dart diff --git a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart new file mode 100644 index 000000000000..c1c00fe7a337 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart @@ -0,0 +1,210 @@ +// Copyright 2013 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:html'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('CameraSettings', () { + late Window window; + late Navigator navigator; + late MediaDevices mediaDevices; + late CameraSettings settings; + + setUp(() async { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + + settings = CameraSettings()..window = window; + }); + + group('getFacingModeForVideoTrack', () { + testWidgets( + 'throws CameraException ' + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + expect( + () => settings.getFacingModeForVideoTrack(MockMediaStreamTrack()), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notSupported, + ), + ), + ); + }); + + testWidgets( + 'returns null ' + 'when the facing mode is not supported', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'facingMode': false, + }); + + final facingMode = + settings.getFacingModeForVideoTrack(MockMediaStreamTrack()); + + expect( + facingMode, + equals(null), + ); + }); + + group('when the facing mode is supported', () { + setUp(() { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'facingMode': true, + }); + }); + + testWidgets( + 'returns an appropriate facing mode ' + 'based on the video track settings', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + when(videoTrack.getSettings).thenReturn({'facingMode': 'user'}); + + final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + + expect( + facingMode, + equals('user'), + ); + }); + + testWidgets( + 'returns an appropriate facing mode ' + 'based on the video track capabilities ' + 'when the facing mode setting is empty', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + when(videoTrack.getSettings).thenReturn({}); + when(videoTrack.getCapabilities).thenReturn({ + 'facingMode': ['environment', 'left'] + }); + + final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + + expect( + facingMode, + equals('environment'), + ); + }); + + testWidgets( + 'returns null ' + 'when the facing mode setting ' + 'and capabilities are empty', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + when(videoTrack.getSettings).thenReturn({}); + when(videoTrack.getCapabilities).thenReturn({'facingMode': []}); + + final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + + expect( + facingMode, + equals(null), + ); + }); + + testWidgets( + 'returns null ' + 'when the facing mode setting is empty and ' + 'the video track capabilities are not supported', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + when(videoTrack.getSettings).thenReturn({}); + when(videoTrack.getCapabilities).thenThrow(JSNoSuchMethodError()); + + final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + + expect( + facingMode, + equals(null), + ); + }); + + testWidgets( + 'throws CameraException ' + 'with unknown error ' + 'when getting the video track capabilities ' + 'throws an unknown error', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + when(videoTrack.getSettings).thenReturn({}); + when(videoTrack.getCapabilities).thenThrow(Exception('Unknown')); + + expect( + () => settings.getFacingModeForVideoTrack(videoTrack), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.unknown, + ), + ), + ); + }); + }); + }); + + group('mapFacingModeToLensDirection', () { + testWidgets( + 'returns front ' + 'when the facing mode is user', (tester) async { + expect( + settings.mapFacingModeToLensDirection('user'), + equals(CameraLensDirection.front), + ); + }); + + testWidgets( + 'returns back ' + 'when the facing mode is environment', (tester) async { + expect( + settings.mapFacingModeToLensDirection('environment'), + equals(CameraLensDirection.back), + ); + }); + + testWidgets( + 'returns external ' + 'when the facing mode is left', (tester) async { + expect( + settings.mapFacingModeToLensDirection('left'), + equals(CameraLensDirection.external), + ); + }); + + testWidgets( + 'returns external ' + 'when the facing mode is right', (tester) async { + expect( + settings.mapFacingModeToLensDirection('right'), + equals(CameraLensDirection.external), + ); + }); + }); + }); +} + +class JSNoSuchMethodError implements Exception {} diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index d26f0e855889..25368daf02f7 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -6,6 +6,8 @@ import 'dart:html'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/camera_web.dart'; +import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/types/types.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -23,6 +25,7 @@ void main() { late Navigator navigator; late MediaDevices mediaDevices; late VideoElement videoElement; + late CameraSettings cameraSettings; setUp(() async { window = MockWindow(); @@ -33,7 +36,10 @@ void main() { 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' ..preload = 'true' ..width = 10 - ..height = 10; + ..height = 10 + ..crossOrigin = 'anonymous'; + + cameraSettings = MockCameraSettings(); when(() => window.navigator).thenReturn(navigator); when(() => navigator.mediaDevices).thenReturn(mediaDevices); @@ -41,18 +47,253 @@ void main() { () => mediaDevices.getUserMedia(any()), ).thenAnswer((_) async => videoElement.captureStream()); - CameraPlatform.instance = CameraPlugin()..window = window; + CameraPlatform.instance = CameraPlugin( + cameraSettings: cameraSettings, + )..window = window; + }); + + setUpAll(() { + registerFallbackValue(MockMediaStreamTrack()); }); testWidgets('CameraPlugin is the live instance', (tester) async { expect(CameraPlatform.instance, isA()); }); - testWidgets('availableCameras throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.availableCameras(), - throwsUnimplementedError, - ); + group('availableCameras', () { + setUp(() { + when( + () => cameraSettings.getFacingModeForVideoTrack( + any(), + ), + ).thenReturn(null); + }); + + testWidgets( + 'throws CameraException ' + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + expect( + () => CameraPlatform.instance.availableCameras(), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notSupported, + ), + ), + ); + }); + + testWidgets( + 'calls MediaDevices.getUserMedia ' + 'on the video input device', (tester) async { + final videoDevice = FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) => Future.value([videoDevice]), + ); + + final _ = await CameraPlatform.instance.availableCameras(); + + verify( + () => mediaDevices.getUserMedia( + CameraOptions( + video: VideoConstraints( + deviceId: videoDevice.deviceId, + ), + ).toJson(), + ), + ).called(1); + }); + + testWidgets( + 'calls CameraSettings.getLensDirectionForVideoTrack ' + 'on the first video track of the video input device', (tester) async { + final videoDevice = FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ); + + final videoStream = + FakeMediaStream([MockMediaStreamTrack(), MockMediaStreamTrack()]); + + when( + () => mediaDevices.getUserMedia( + CameraOptions( + video: VideoConstraints(deviceId: videoDevice.deviceId), + ).toJson(), + ), + ).thenAnswer((_) => Future.value(videoStream)); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) => Future.value([videoDevice]), + ); + + final _ = await CameraPlatform.instance.availableCameras(); + + verify( + () => cameraSettings.getFacingModeForVideoTrack( + videoStream.getVideoTracks().first, + ), + ).called(1); + }); + + testWidgets( + 'returns appropriate camera descriptions ' + 'for multiple media devices', (tester) async { + final firstVideoDevice = FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ); + + final secondVideoDevice = FakeMediaDeviceInfo( + '4', + 'Camera 4', + MediaDeviceKind.videoInput, + ); + + // Create a video stream for the first video device. + final firstVideoStream = + FakeMediaStream([MockMediaStreamTrack(), MockMediaStreamTrack()]); + + // Create a video stream for the second video device. + final secondVideoStream = FakeMediaStream([MockMediaStreamTrack()]); + + // Mock media devices to return two video input devices + // and two audio devices. + when(mediaDevices.enumerateDevices).thenAnswer( + (_) => Future.value([ + firstVideoDevice, + FakeMediaDeviceInfo( + '2', + 'Camera 2', + MediaDeviceKind.audioInput, + ), + FakeMediaDeviceInfo( + '3', + 'Camera 3', + MediaDeviceKind.audioOutput, + ), + secondVideoDevice, + ]), + ); + + // Mock media devices to return the first video stream + // for the first video device. + when( + () => mediaDevices.getUserMedia( + CameraOptions( + video: VideoConstraints(deviceId: firstVideoDevice.deviceId), + ).toJson(), + ), + ).thenAnswer((_) => Future.value(firstVideoStream)); + + // Mock media devices to return the second video stream + // for the second video device. + when( + () => mediaDevices.getUserMedia( + CameraOptions( + video: VideoConstraints(deviceId: secondVideoDevice.deviceId), + ).toJson(), + ), + ).thenAnswer((_) => Future.value(secondVideoStream)); + + // Mock camera settings to return a user facing mode + // for the first video stream. + when( + () => cameraSettings.getFacingModeForVideoTrack( + firstVideoStream.getVideoTracks().first, + ), + ).thenReturn('user'); + + when(() => cameraSettings.mapFacingModeToLensDirection('user')) + .thenReturn(CameraLensDirection.front); + + // Mock camera settings to return an environment facing mode + // for the second video stream. + when( + () => cameraSettings.getFacingModeForVideoTrack( + secondVideoStream.getVideoTracks().first, + ), + ).thenReturn('environment'); + + when(() => cameraSettings.mapFacingModeToLensDirection('environment')) + .thenReturn(CameraLensDirection.back); + + final cameras = await CameraPlatform.instance.availableCameras(); + + // Expect two cameras and ignore two audio devices. + expect( + cameras, + equals([ + CameraDescription( + name: firstVideoDevice.label!, + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ), + CameraDescription( + name: secondVideoDevice.label!, + lensDirection: CameraLensDirection.back, + sensorOrientation: 0, + ) + ]), + ); + }); + + testWidgets( + 'sets camera metadata ' + 'for the camera description', (tester) async { + final videoDevice = FakeMediaDeviceInfo( + '1', + 'Camera 1', + MediaDeviceKind.videoInput, + ); + + final videoStream = + FakeMediaStream([MockMediaStreamTrack(), MockMediaStreamTrack()]); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) => Future.value([videoDevice]), + ); + + when( + () => mediaDevices.getUserMedia( + CameraOptions( + video: VideoConstraints(deviceId: videoDevice.deviceId), + ).toJson(), + ), + ).thenAnswer((_) => Future.value(videoStream)); + + when( + () => cameraSettings.getFacingModeForVideoTrack( + videoStream.getVideoTracks().first, + ), + ).thenReturn('left'); + + when(() => cameraSettings.mapFacingModeToLensDirection('left')) + .thenReturn(CameraLensDirection.external); + + final camera = (await CameraPlatform.instance.availableCameras()).first; + + expect( + (CameraPlatform.instance as CameraPlugin).camerasMetadata, + equals({ + camera: CameraMetadata( + deviceId: videoDevice.deviceId!, + facingMode: 'left', + ) + }), + ); + }); }); testWidgets('createCamera throws UnimplementedError', (tester) async { diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 03be3f0b3ca6..3702aee8e184 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -4,6 +4,7 @@ import 'dart:html'; +import 'package:camera_web/src/camera_settings.dart'; import 'package:mocktail/mocktail.dart'; class MockWindow extends Mock implements Window {} @@ -12,12 +13,44 @@ class MockNavigator extends Mock implements Navigator {} class MockMediaDevices extends Mock implements MediaDevices {} -/// A fake [DomException] that returns the provided [errorName]. +class MockCameraSettings extends Mock implements CameraSettings {} + +class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} + +/// A fake [MediaStream] that returns the provided [_videoTracks]. +class FakeMediaStream extends Fake implements MediaStream { + FakeMediaStream(this._videoTracks); + + final List _videoTracks; + + @override + List getVideoTracks() => _videoTracks; +} + +/// A fake [MediaDeviceInfo] that returns the provided [_deviceId], [_label] and [_kind]. +class FakeMediaDeviceInfo extends Fake implements MediaDeviceInfo { + FakeMediaDeviceInfo(this._deviceId, this._label, this._kind); + + final String _deviceId; + final String _label; + final String _kind; + + @override + String? get deviceId => _deviceId; + + @override + String? get label => _label; + + @override + String? get kind => _kind; +} + +/// A fake [DomException] that returns the provided error [_name]. class FakeDomException extends Fake implements DomException { - FakeDomException(this.errorName); + FakeDomException(this._name); - final String errorName; + final String _name; @override - String get name => errorName; + String get name => _name; } diff --git a/packages/camera/camera_web/lib/src/camera_settings.dart b/packages/camera/camera_web/lib/src/camera_settings.dart new file mode 100644 index 000000000000..2a1a31ff1cf5 --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera_settings.dart @@ -0,0 +1,108 @@ +// Copyright 2013 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:html' as html; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/types/types.dart'; +import 'package:flutter/foundation.dart'; + +/// A utility to fetch and map camera settings. +class CameraSettings { + // A facing mode constraint name. + static const _facingModeKey = "facingMode"; + + /// The current browser window used to access media devices. + @visibleForTesting + html.Window? window = html.window; + + /// Returns a facing mode of the [videoTrack] + /// (null if the facing mode is not available). + String? getFacingModeForVideoTrack(html.MediaStreamTrack videoTrack) { + final mediaDevices = window?.navigator.mediaDevices; + + // Throw a not supported exception if the current browser window + // does not support any media devices. + if (mediaDevices == null) { + throw CameraException( + CameraErrorCodes.notSupported, + 'The camera is not supported on this device.', + ); + } + + // Check if the camera facing mode is supported by the current browser. + final supportedConstraints = mediaDevices.getSupportedConstraints(); + final facingModeSupported = supportedConstraints[_facingModeKey] ?? false; + + // Return null if the facing mode is not supported. + if (!facingModeSupported) { + return null; + } + + // Extract the facing mode from the video track settings. + // The property may not be available if it's not supported + // by the browser or not available due to context. + // + // MediaTrackSettings: + // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings + final videoTrackSettings = videoTrack.getSettings(); + final facingMode = videoTrackSettings[_facingModeKey]; + + if (facingMode == null) { + try { + // If the facing mode does not exist in the video track settings, + // check for the facing mode in the video track capabilities. + // + // MediaTrackCapabilities: + // https://www.w3.org/TR/mediacapture-streams/#dom-mediatrackcapabilities + // + // This may throw a not supported error on Firefox. + final videoTrackCapabilities = videoTrack.getCapabilities(); + + // A list of facing mode capabilities as + // the camera may support multiple facing modes. + final facingModeCapabilities = + List.from(videoTrackCapabilities[_facingModeKey] ?? []); + + if (facingModeCapabilities.isNotEmpty) { + final facingModeCapability = facingModeCapabilities.first; + return facingModeCapability; + } else { + // Return null if there are no facing mode capabilities. + return null; + } + } catch (e) { + switch (e.runtimeType.toString()) { + case 'JSNoSuchMethodError': + // Return null if getting capabilities is currently not supported. + return null; + default: + throw CameraException( + CameraErrorCodes.unknown, + 'An unknown error occured when getting the video track capabilities.', + ); + } + } + } + + return facingMode; + } + + /// Maps the given [facingMode] to [CameraLensDirection]. + /// + /// The following values for the facing mode are supported: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings/facingMode + CameraLensDirection mapFacingModeToLensDirection(String facingMode) { + switch (facingMode) { + case 'user': + return CameraLensDirection.front; + case 'environment': + return CameraLensDirection.back; + case 'left': + case 'right': + default: + return CameraLensDirection.external; + } + } +} diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index fc3be09eec1d..ae9937dd94d3 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -7,6 +7,8 @@ import 'dart:html' as html; import 'dart:math'; import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/types/types.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -15,18 +17,112 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; /// /// This class implements the `package:camera` functionality for the web. class CameraPlugin extends CameraPlatform { + /// Creates a new instance of [CameraPlugin] + /// with the given [cameraSettings] utility. + CameraPlugin({required CameraSettings cameraSettings}) + : _cameraSettings = cameraSettings; + /// Registers this class as the default instance of [CameraPlatform]. static void registerWith(Registrar registrar) { - CameraPlatform.instance = CameraPlugin(); + CameraPlatform.instance = CameraPlugin( + cameraSettings: CameraSettings(), + ); } - /// The current browser window used to access device cameras. + final CameraSettings _cameraSettings; + + /// Metadata associated with each camera description. + /// Populated in [availableCameras]. + @visibleForTesting + final camerasMetadata = {}; + + /// The current browser window used to access media devices. @visibleForTesting - html.Window? window; + html.Window? window = html.window; @override - Future> availableCameras() { - throw UnimplementedError('availableCameras() is not implemented.'); + Future> availableCameras() async { + final mediaDevices = window?.navigator.mediaDevices; + final cameras = []; + + // Throw a not supported exception if the current browser window + // does not support any media devices. + if (mediaDevices == null) { + throw CameraException( + CameraErrorCodes.notSupported, + 'The camera is not supported on this device.', + ); + } + + // Request available media devices. + final devices = await mediaDevices.enumerateDevices(); + + // Filter video input devices. + final videoInputDevices = devices + .whereType() + .where((device) => device.kind == MediaDeviceKind.videoInput) + + /// The device id property is currently not supported on Internet Explorer: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/deviceId#browser_compatibility + .where((device) => device.deviceId != null); + + // Map video input devices to camera descriptions. + for (final videoInputDevice in videoInputDevices) { + // Get the video stream for the current video input device + // to later use for the available video tracks. + final videoStream = await _getVideoStreamForDevice( + mediaDevices, + videoInputDevice.deviceId!, + ); + + // Get all video tracks in the video stream + // to later extract the lens direction from the first track. + final videoTracks = videoStream.getVideoTracks(); + + if (videoTracks.isNotEmpty) { + // Get the facing mode from the first available video track. + final facingMode = _cameraSettings.getFacingModeForVideoTrack( + videoTracks.first, + ); + + // Get the lens direction based on the facing mode. + // Fallback to the external lens direction + // if the facing mode is not available. + final lensDirection = facingMode != null + ? _cameraSettings.mapFacingModeToLensDirection(facingMode) + : CameraLensDirection.external; + + // Create a camera description. + // + // The name is a camera label which might be empty + // if no permissions to media devices have been granted. + // + // MediaDeviceInfo.label: + // https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/label + // + // Sensor orientation is currently not supported. + final cameraLabel = videoInputDevice.label ?? ''; + final camera = CameraDescription( + name: cameraLabel, + lensDirection: lensDirection, + sensorOrientation: 0, + ); + + final cameraMetadata = CameraMetadata( + deviceId: videoInputDevice.deviceId!, + facingMode: facingMode, + ); + + cameras.add(camera); + + camerasMetadata[camera] = cameraMetadata; + } else { + // Ignore as no video tracks exist in the current video input device. + continue; + } + } + + return cameras; } @override @@ -190,4 +286,17 @@ class CameraPlugin extends CameraPlatform { Future dispose(int cameraId) { throw UnimplementedError('dispose() is not implemented.'); } + + /// Returns a media video stream for the device with the given [deviceId]. + Future _getVideoStreamForDevice( + html.MediaDevices mediaDevices, + String deviceId, + ) { + // Create camera options with the desired device id. + final cameraOptions = CameraOptions( + video: VideoConstraints(deviceId: deviceId), + ); + + return mediaDevices.getUserMedia(cameraOptions.toJson()); + } } diff --git a/packages/camera/camera_web/lib/src/types/camera_metadata.dart b/packages/camera/camera_web/lib/src/types/camera_metadata.dart new file mode 100644 index 000000000000..c9998e58a52c --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_metadata.dart @@ -0,0 +1,37 @@ +// Copyright 2013 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:ui' show hashValues; + +/// Metadata used along the camera description +/// to store additional web-specific camera details. +class CameraMetadata { + /// Creates a new instance of [CameraMetadata] + /// with the given [deviceId] and [facingMode]. + const CameraMetadata({required this.deviceId, required this.facingMode}); + + /// Uniquely identifies the camera device. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/deviceId + final String deviceId; + + /// Describes the direction the camera is facing towards. + /// May be `user`, `environment`, `left`, `right` + /// or null if the facing mode is not available. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings/facingMode + final String? facingMode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is CameraMetadata && + other.deviceId == deviceId && + other.facingMode == facingMode; + } + + @override + int get hashCode => hashValues(deviceId.hashCode, facingMode.hashCode); +} diff --git a/packages/camera/camera_web/lib/src/types/media_device_kind.dart b/packages/camera/camera_web/lib/src/types/media_device_kind.dart new file mode 100644 index 000000000000..1f746808df9e --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/media_device_kind.dart @@ -0,0 +1,17 @@ +// Copyright 2013 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. + +/// A kind of a media device. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/kind +abstract class MediaDeviceKind { + /// A video input media device kind. + static const videoInput = 'videoinput'; + + /// An audio input media device kind. + static const audioInput = 'audioinput'; + + /// An audio output media device kind. + static const audioOutput = 'audiooutput'; +} diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart index fc1f931679ff..1a15503715cd 100644 --- a/packages/camera/camera_web/lib/src/types/types.dart +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -3,4 +3,6 @@ // found in the LICENSE file. export 'camera_error_codes.dart'; +export 'camera_metadata.dart'; export 'camera_options.dart'; +export 'media_device_kind.dart'; diff --git a/packages/camera/camera_web/test/types/camera_metadata_test.dart b/packages/camera/camera_web/test/types/camera_metadata_test.dart new file mode 100644 index 000000000000..c76688f768d7 --- /dev/null +++ b/packages/camera/camera_web/test/types/camera_metadata_test.dart @@ -0,0 +1,25 @@ +// Copyright 2013 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 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CameraMetadata', () { + test('supports value equality', () { + expect( + CameraMetadata( + deviceId: 'deviceId', + facingMode: 'environment', + ), + equals( + CameraMetadata( + deviceId: 'deviceId', + facingMode: 'environment', + ), + ), + ); + }); + }); +} From e51722c734bb72da3a3a3dcb588e4c82ba8cbdb1 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 22 Jul 2021 14:41:06 +0200 Subject: [PATCH 146/364] [webview_flutter] Refactored creation of Android WebView for testability. (#4178) --- .../webview_flutter/android/build.gradle | 2 + .../webviewflutter/FlutterWebView.java | 78 +++++++--- ...actory.java => FlutterWebViewFactory.java} | 8 +- .../webviewflutter/WebViewBuilder.java | 141 ++++++++++++++++++ .../webviewflutter/WebViewFlutterPlugin.java | 5 +- .../webviewflutter/FlutterWebViewTest.java | 61 ++++++++ .../webviewflutter/WebViewBuilderTest.java | 99 ++++++++++++ 7 files changed, 369 insertions(+), 25 deletions(-) rename packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/{WebViewFactory.java => FlutterWebViewFactory.java} (71%) create mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java create mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java create mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java diff --git a/packages/webview_flutter/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle index 45f769b4bc59..41c702f9fc56 100644 --- a/packages/webview_flutter/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/webview_flutter/android/build.gradle @@ -37,5 +37,7 @@ android { implementation 'androidx.annotation:annotation:1.0.0' implementation 'androidx.webkit:webkit:1.0.0' testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.11.1' + testImplementation 'androidx.test:core:1.3.0' } } diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index ebc7c31987f4..a3b681f27980 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -17,7 +17,7 @@ import android.webkit.WebView; import android.webkit.WebViewClient; import androidx.annotation.NonNull; -import io.flutter.plugin.common.BinaryMessenger; +import androidx.annotation.VisibleForTesting; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; @@ -28,6 +28,7 @@ import java.util.Map; public class FlutterWebView implements PlatformView, MethodCallHandler { + private static final String JS_CHANNEL_NAMES_FIELD = "javascriptChannelNames"; private final WebView webView; private final MethodChannel methodChannel; @@ -36,6 +37,7 @@ public class FlutterWebView implements PlatformView, MethodCallHandler { // Verifies that a url opened by `Window.open` has a secure url. private class FlutterWebChromeClient extends WebChromeClient { + @Override public boolean onCreateWindow( final WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) { @@ -83,8 +85,7 @@ public void onProgressChanged(WebView view, int progress) { @SuppressWarnings("unchecked") FlutterWebView( final Context context, - BinaryMessenger messenger, - int id, + MethodChannel methodChannel, Map params, View containerView) { @@ -93,37 +94,34 @@ public void onProgressChanged(WebView view, int progress) { (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); displayListenerProxy.onPreWebViewInitialization(displayManager); - Boolean usesHybridComposition = (Boolean) params.get("usesHybridComposition"); webView = - (usesHybridComposition) - ? new WebView(context) - : new InputAwareWebView(context, containerView); + createWebView( + new WebViewBuilder(context, containerView), params, new FlutterWebChromeClient()); displayListenerProxy.onPostWebViewInitialization(displayManager); platformThreadHandler = new Handler(context.getMainLooper()); - // Allow local storage. - webView.getSettings().setDomStorageEnabled(true); - webView.getSettings().setJavaScriptCanOpenWindowsAutomatically(true); - - // Multi windows is set with FlutterWebChromeClient by default to handle internal bug: b/159892679. - webView.getSettings().setSupportMultipleWindows(true); - webView.setWebChromeClient(new FlutterWebChromeClient()); - methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id); - methodChannel.setMethodCallHandler(this); + this.methodChannel = methodChannel; + this.methodChannel.setMethodCallHandler(this); flutterWebViewClient = new FlutterWebViewClient(methodChannel); Map settings = (Map) params.get("settings"); - if (settings != null) applySettings(settings); + if (settings != null) { + applySettings(settings); + } if (params.containsKey(JS_CHANNEL_NAMES_FIELD)) { List names = (List) params.get(JS_CHANNEL_NAMES_FIELD); - if (names != null) registerJavaScriptChannelNames(names); + if (names != null) { + registerJavaScriptChannelNames(names); + } } Integer autoMediaPlaybackPolicy = (Integer) params.get("autoMediaPlaybackPolicy"); - if (autoMediaPlaybackPolicy != null) updateAutoMediaPlaybackPolicy(autoMediaPlaybackPolicy); + if (autoMediaPlaybackPolicy != null) { + updateAutoMediaPlaybackPolicy(autoMediaPlaybackPolicy); + } if (params.containsKey("userAgent")) { String userAgent = (String) params.get("userAgent"); updateUserAgent(userAgent); @@ -134,6 +132,44 @@ public void onProgressChanged(WebView view, int progress) { } } + /** + * Creates a {@link android.webkit.WebView} and configures it according to the supplied + * parameters. + * + *

    The {@link WebView} is configured with the following predefined settings: + * + *

      + *
    • always enable the DOM storage API; + *
    • always allow JavaScript to automatically open windows; + *
    • always allow support for multiple windows; + *
    • always use the {@link FlutterWebChromeClient} as web Chrome client. + *
    + * + *

    Important: This method is visible for testing purposes only and should + * never be called from outside this class. + * + * @param webViewBuilder a {@link WebViewBuilder} which is responsible for building the {@link + * WebView}. + * @param params creation parameters received over the method channel. + * @param webChromeClient an implementation of WebChromeClient This value may be null. + * @return The new {@link android.webkit.WebView} object. + */ + @VisibleForTesting + static WebView createWebView( + WebViewBuilder webViewBuilder, Map params, WebChromeClient webChromeClient) { + boolean usesHybridComposition = Boolean.TRUE.equals(params.get("usesHybridComposition")); + webViewBuilder + .setUsesHybridComposition(usesHybridComposition) + .setDomStorageEnabled(true) // Always enable DOM storage API. + .setJavaScriptCanOpenWindowsAutomatically( + true) // Always allow automatically opening of windows. + .setSupportMultipleWindows(true) // Always support multiple windows. + .setWebChromeClient( + webChromeClient); // Always use {@link FlutterWebChromeClient} as web Chrome client. + + return webViewBuilder.build(); + } + @Override public View getView() { return webView; @@ -369,7 +405,9 @@ private void applySettings(Map settings) { switch (key) { case "jsMode": Integer mode = (Integer) settings.get(key); - if (mode != null) updateJsMode(mode); + if (mode != null) { + updateJsMode(mode); + } break; case "hasNavigationDelegate": final boolean hasNavigationDelegate = (boolean) settings.get(key); diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java similarity index 71% rename from packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java rename to packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java index 22de668e0126..8fe58104a0fb 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFactory.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java @@ -7,16 +7,17 @@ import android.content.Context; import android.view.View; import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.StandardMessageCodec; import io.flutter.plugin.platform.PlatformView; import io.flutter.plugin.platform.PlatformViewFactory; import java.util.Map; -public final class WebViewFactory extends PlatformViewFactory { +public final class FlutterWebViewFactory extends PlatformViewFactory { private final BinaryMessenger messenger; private final View containerView; - WebViewFactory(BinaryMessenger messenger, View containerView) { + FlutterWebViewFactory(BinaryMessenger messenger, View containerView) { super(StandardMessageCodec.INSTANCE); this.messenger = messenger; this.containerView = containerView; @@ -26,6 +27,7 @@ public final class WebViewFactory extends PlatformViewFactory { @Override public PlatformView create(Context context, int id, Object args) { Map params = (Map) args; - return new FlutterWebView(context, messenger, id, params, containerView); + MethodChannel methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id); + return new FlutterWebView(context, methodChannel, params, containerView); } } diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java new file mode 100644 index 000000000000..6b8cc51febe8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java @@ -0,0 +1,141 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.content.Context; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** Builder used to create {@link android.webkit.WebView} objects. */ +public class WebViewBuilder { + + /** Factory used to create a new {@link android.webkit.WebView} instance. */ + static class WebViewFactory { + + /** + * Creates a new {@link android.webkit.WebView} instance. + * + * @param context an Activity Context to access application assets. This value cannot be null. + * @param usesHybridComposition If {@code false} a {@link InputAwareWebView} instance is + * returned. + * @param containerView must be supplied when the {@code useHybridComposition} parameter is set + * to {@code false}. Used to create an InputConnection on the WebView's dedicated input, or + * IME, thread (see also {@link InputAwareWebView}) + * @return A new instance of the {@link android.webkit.WebView} object. + */ + static WebView create(Context context, boolean usesHybridComposition, View containerView) { + return usesHybridComposition + ? new WebView(context) + : new InputAwareWebView(context, containerView); + } + } + + private final Context context; + private final View containerView; + + private boolean enableDomStorage; + private boolean javaScriptCanOpenWindowsAutomatically; + private boolean supportMultipleWindows; + private boolean usesHybridComposition; + private WebChromeClient webChromeClient; + + /** + * Constructs a new {@link WebViewBuilder} object with a custom implementation of the {@link + * WebViewFactory} object. + * + * @param context an Activity Context to access application assets. This value cannot be null. + * @param containerView must be supplied when the {@code useHybridComposition} parameter is set to + * {@code false}. Used to create an InputConnection on the WebView's dedicated input, or IME, + * thread (see also {@link InputAwareWebView}) + */ + WebViewBuilder(@NonNull final Context context, View containerView) { + this.context = context; + this.containerView = containerView; + } + + /** + * Sets whether the DOM storage API is enabled. The default value is {@code false}. + * + * @param flag {@code true} is {@link android.webkit.WebView} should use the DOM storage API. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setDomStorageEnabled(boolean flag) { + this.enableDomStorage = flag; + return this; + } + + /** + * Sets whether JavaScript is allowed to open windows automatically. This applies to the + * JavaScript function {@code window.open()}. The default value is {@code false}. + * + * @param flag {@code true} if JavaScript is allowed to open windows automatically. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setJavaScriptCanOpenWindowsAutomatically(boolean flag) { + this.javaScriptCanOpenWindowsAutomatically = flag; + return this; + } + + /** + * Sets whether the {@link WebView} supports multiple windows. If set to {@code true}, {@link + * WebChromeClient#onCreateWindow} must be implemented by the host application. The default is + * {@code false}. + * + * @param flag {@code true} if multiple windows are supported. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setSupportMultipleWindows(boolean flag) { + this.supportMultipleWindows = flag; + return this; + } + + /** + * Sets whether the hybrid composition should be used. + * + *

    If set to {@code true} a standard {@link WebView} is created. If set to {@code false} the + * {@link WebViewBuilder} will create a {@link InputAwareWebView} to workaround issues using the + * {@link WebView} on Android versions below N. + * + * @param flag {@code true} if uses hybrid composition. The default is {@code false}. + * @return This builder. This value cannot be {@code null} + */ + public WebViewBuilder setUsesHybridComposition(boolean flag) { + this.usesHybridComposition = flag; + return this; + } + + /** + * Sets the chrome handler. This is an implementation of WebChromeClient for use in handling + * JavaScript dialogs, favicons, titles, and the progress. This will replace the current handler. + * + * @param webChromeClient an implementation of WebChromeClient This value may be null. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setWebChromeClient(@Nullable WebChromeClient webChromeClient) { + this.webChromeClient = webChromeClient; + return this; + } + + /** + * Build the {@link android.webkit.WebView} using the current settings. + * + * @return The {@link android.webkit.WebView} using the current settings. + */ + public WebView build() { + WebView webView = WebViewFactory.create(context, usesHybridComposition, containerView); + + WebSettings webSettings = webView.getSettings(); + webSettings.setDomStorageEnabled(enableDomStorage); + webSettings.setJavaScriptCanOpenWindowsAutomatically(javaScriptCanOpenWindowsAutomatically); + webSettings.setSupportMultipleWindows(supportMultipleWindows); + webView.setWebChromeClient(webChromeClient); + + return webView; + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java index dc329e2273d0..268d35a1e04c 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java @@ -46,7 +46,7 @@ public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registra .platformViewRegistry() .registerViewFactory( "plugins.flutter.io/webview", - new WebViewFactory(registrar.messenger(), registrar.view())); + new FlutterWebViewFactory(registrar.messenger(), registrar.view())); new FlutterCookieManager(registrar.messenger()); } @@ -56,7 +56,8 @@ public void onAttachedToEngine(FlutterPluginBinding binding) { binding .getPlatformViewRegistry() .registerViewFactory( - "plugins.flutter.io/webview", new WebViewFactory(messenger, /*containerView=*/ null)); + "plugins.flutter.io/webview", + new FlutterWebViewFactory(messenger, /*containerView=*/ null)); flutterCookieManager = new FlutterCookieManager(messenger); } diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java new file mode 100644 index 000000000000..96cbdece387c --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java @@ -0,0 +1,61 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class FlutterWebViewTest { + private WebChromeClient mockWebChromeClient; + private WebViewBuilder mockWebViewBuilder; + private WebView mockWebView; + + @Before + public void before() { + mockWebChromeClient = mock(WebChromeClient.class); + mockWebViewBuilder = mock(WebViewBuilder.class); + mockWebView = mock(WebView.class); + + when(mockWebViewBuilder.setDomStorageEnabled(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setJavaScriptCanOpenWindowsAutomatically(anyBoolean())) + .thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setSupportMultipleWindows(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setUsesHybridComposition(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setWebChromeClient(any(WebChromeClient.class))) + .thenReturn(mockWebViewBuilder); + + when(mockWebViewBuilder.build()).thenReturn(mockWebView); + } + + @Test + public void createWebView_should_create_webview_with_default_configuration() { + FlutterWebView.createWebView( + mockWebViewBuilder, createParameterMap(false), mockWebChromeClient); + + verify(mockWebViewBuilder, times(1)).setDomStorageEnabled(true); + verify(mockWebViewBuilder, times(1)).setJavaScriptCanOpenWindowsAutomatically(true); + verify(mockWebViewBuilder, times(1)).setSupportMultipleWindows(true); + verify(mockWebViewBuilder, times(1)).setUsesHybridComposition(false); + verify(mockWebViewBuilder, times(1)).setWebChromeClient(mockWebChromeClient); + } + + private Map createParameterMap(boolean usesHybridComposition) { + Map params = new HashMap<>(); + params.put("usesHybridComposition", usesHybridComposition); + + return params; + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java new file mode 100644 index 000000000000..48fbce231ed5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java @@ -0,0 +1,99 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.*; + +import android.content.Context; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import io.flutter.plugins.webviewflutter.WebViewBuilder.WebViewFactory; +import java.io.IOException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.MockedStatic.Verification; + +public class WebViewBuilderTest { + private Context mockContext; + private View mockContainerView; + private WebView mockWebView; + private MockedStatic mockedStaticWebViewFactory; + + @Before + public void before() { + mockContext = mock(Context.class); + mockContainerView = mock(View.class); + mockWebView = mock(WebView.class); + mockedStaticWebViewFactory = mockStatic(WebViewFactory.class); + + mockedStaticWebViewFactory + .when( + new Verification() { + @Override + public void apply() { + WebViewFactory.create(mockContext, false, mockContainerView); + } + }) + .thenReturn(mockWebView); + } + + @After + public void after() { + mockedStaticWebViewFactory.close(); + } + + @Test + public void ctor_test() { + WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); + + assertNotNull(builder); + } + + @Test + public void build_should_set_values() throws IOException { + WebSettings mockWebSettings = mock(WebSettings.class); + WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + + when(mockWebView.getSettings()).thenReturn(mockWebSettings); + + WebViewBuilder builder = + new WebViewBuilder(mockContext, mockContainerView) + .setDomStorageEnabled(true) + .setJavaScriptCanOpenWindowsAutomatically(true) + .setSupportMultipleWindows(true) + .setWebChromeClient(mockWebChromeClient); + + WebView webView = builder.build(); + + assertNotNull(webView); + verify(mockWebSettings).setDomStorageEnabled(true); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(true); + verify(mockWebSettings).setSupportMultipleWindows(true); + verify(mockWebView).setWebChromeClient(mockWebChromeClient); + } + + @Test + public void build_should_use_default_values() throws IOException { + WebSettings mockWebSettings = mock(WebSettings.class); + WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + + when(mockWebView.getSettings()).thenReturn(mockWebSettings); + + WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); + + WebView webView = builder.build(); + + assertNotNull(webView); + verify(mockWebSettings).setDomStorageEnabled(false); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false); + verify(mockWebSettings).setSupportMultipleWindows(false); + verify(mockWebView).setWebChromeClient(null); + } +} From b6d1345a5398a45ae90ba9769ca63789b300ce40 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 22 Jul 2021 11:14:17 -0700 Subject: [PATCH 147/364] [flutter_plugin_tools] Replace xctest and java-test with native-test (#4176) Creates a new `native-test` command that will be used to run native unit and UI/integration tests for all platforms over time. This replaces both `xctest` and `java-test`. For CI we can continue to run each platform separately for clarity, but the combined command makes it easier to use (and remember how to use) for local development, as well as avoiding the need to introduce several new commands for desktop testing as support for that is added to the tool. Fixes https://github.com/flutter/flutter/issues/84392 Fixes https://github.com/flutter/flutter/issues/86489 --- .cirrus.yml | 18 +- script/tool/CHANGELOG.md | 10 +- script/tool/README.md | 24 +- .../src/common/package_looping_command.dart | 4 +- script/tool/lib/src/java_test_command.dart | 78 -- script/tool/lib/src/main.dart | 8 +- script/tool/lib/src/native_test_command.dart | 377 ++++++ script/tool/lib/src/xctest_command.dart | 211 ---- script/tool/test/java_test_command_test.dart | 187 --- .../tool/test/native_test_command_test.dart | 1071 +++++++++++++++++ script/tool/test/xctest_command_test.dart | 705 ----------- 11 files changed, 1491 insertions(+), 1202 deletions(-) delete mode 100644 script/tool/lib/src/java_test_command.dart create mode 100644 script/tool/lib/src/native_test_command.dart delete mode 100644 script/tool/lib/src/xctest_command.dart delete mode 100644 script/tool/test/java_test_command_test.dart create mode 100644 script/tool/test/native_test_command_test.dart delete mode 100644 script/tool/test/xctest_command_test.dart diff --git a/.cirrus.yml b/.cirrus.yml index 96902cfd6d15..edefc19bd21a 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -125,7 +125,7 @@ task: memory: 12G matrix: ### Android tasks ### - - name: build-apks+java-test+firebase-test-lab + - name: build-apks+android-unit+firebase-test-lab env: matrix: PLUGIN_SHARDING: "--shardIndex 0 --shardCount 4" @@ -160,13 +160,15 @@ task: - export CIRRUS_CHANGE_MESSAGE="" - export CIRRUS_COMMIT_MESSAGE="" - ./script/tool_runner.sh build-examples --apk - java_test_script: + native_unit_test_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. # TODO(stuartmorgan): See https://github.com/flutter/flutter/issues/24935 - export CIRRUS_CHANGE_MESSAGE="" - export CIRRUS_COMMIT_MESSAGE="" - - ./script/tool_runner.sh java-test # must come after apk build + # Native integration tests are handled by firebase-test-lab below, so + # only run unit tests. + - ./script/tool_runner.sh native-test --android --no-integration # must come after apk build firebase_test_lab_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. @@ -239,12 +241,12 @@ task: - ./script/tool_runner.sh build-examples --ios xcode_analyze_script: - ./script/tool_runner.sh xcode-analyze --ios - xctest_script: - - ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" + native_test_script: + - ./script/tool_runner.sh native-test --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" drive_script: # `drive-examples` contains integration tests, which changes the UI of the application. # This UI change sometimes affects `xctest`. - # So we run `drive-examples` after `xctest`, changing the order will result ci failure. + # So we run `drive-examples` after `native-test`; changing the order will result ci failure. - ./script/tool_runner.sh drive-examples --ios --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS ### macOS desktop tasks ### - name: build_all_plugins_macos @@ -269,7 +271,7 @@ task: - ./script/tool_runner.sh build-examples --macos xcode_analyze_script: - ./script/tool_runner.sh xcode-analyze --macos - xctest_script: - - ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS + native_test_script: + - ./script/tool_runner.sh native-test --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS drive_script: - ./script/tool_runner.sh drive-examples --macos diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index d701278ee76f..dc30c05f79c8 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -2,13 +2,21 @@ - Added an `xctest` flag to select specific test targets, to allow running only unit tests or integration tests. -- Split Xcode analysis out of `xctest` and into a new `xcode-analyze` command. +- **Breaking change**: Split Xcode analysis out of `xctest` and into a new + `xcode-analyze` command. - Fixed a bug that caused `firebase-test-lab` to hang if it tried to run more than one plugin's tests in a single run. - **Breaking change**: If `firebase-test-lab` is run on a package that supports Android, but for which no tests are run, it now fails instead of skipping. This matches `drive-examples`, as this command is what is used for driving Android Flutter integration tests on CI. +- **Breaking change**: Replaced `xctest` with a new `native-test` command that + will eventually be able to run native unit and integration tests for all + platforms. + - Adds the ability to disable test types via `--no-unit` or + `--no-integration`. +- **Breaking change**: Replaced `java-test` with Android unit test support for + the new `native-test` command. ## 0.4.1 diff --git a/script/tool/README.md b/script/tool/README.md index 5629dc50646b..1a87f098757b 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -75,14 +75,28 @@ cd dart run ./script/tool/bin/flutter_plugin_tools.dart test --packages plugin_name ``` -### Run XCTests +### Run Dart Integration Tests ```sh cd -# For iOS: -dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --ios --packages plugin_name -# For macOS: -dart run ./script/tool/bin/flutter_plugin_tools.dart xctest --macos --packages plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart build-examples --packages plugin_name +dart run ./script/tool/bin/flutter_plugin_tools.dart drive-examples --packages plugin_name +``` + +### Run Native Tests + +`native-test` takes one or more platform flags to run tests for. By default it +runs both unit tests and (on platforms that support it) integration tests, but +`--no-unit` or `--no-integration` can be used to run just one type. + +Examples: + +```sh +cd +# Run just unit tests for iOS and Android: +dart run ./script/tool/bin/flutter_plugin_tools.dart native-test --ios --android --no-integration --packages plugin_name +# Run all tests for macOS: +dart run ./script/tool/bin/flutter_plugin_tools.dart native-test --macos --packages plugin_name ``` ### Publish a Release diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 9f4039ec7074..0bcde6d296d3 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -165,9 +165,9 @@ abstract class PackageLoopingCommand extends PluginCommand { final List components = p.posix.split(packageName); // For the common federated plugin pattern of `foo/foo_subpackage`, drop // the first part since it's not useful. - if (components.length == 2 && + if (components.length >= 2 && components[1].startsWith('${components[0]}_')) { - packageName = components[1]; + packageName = p.posix.joinAll(components.sublist(1)); } return packageName; } diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart deleted file mode 100644 index b36d1102f109..000000000000 --- a/script/tool/lib/src/java_test_command.dart +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2013 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 'package:file/file.dart'; -import 'package:platform/platform.dart'; - -import 'common/core.dart'; -import 'common/package_looping_command.dart'; -import 'common/process_runner.dart'; - -/// A command to run the Java tests of Android plugins. -class JavaTestCommand extends PackageLoopingCommand { - /// Creates an instance of the test runner. - JavaTestCommand( - Directory packagesDir, { - ProcessRunner processRunner = const ProcessRunner(), - Platform platform = const LocalPlatform(), - }) : super(packagesDir, processRunner: processRunner, platform: platform); - - static const String _gradleWrapper = 'gradlew'; - - @override - final String name = 'java-test'; - - @override - final String description = 'Runs the Java tests of the example apps.\n\n' - 'Building the apks of the example apps is required before executing this' - 'command.'; - - @override - Future runForPackage(Directory package) async { - final Iterable examplesWithTests = getExamplesForPlugin(package) - .where((Directory d) => - isFlutterPackage(d) && - (d - .childDirectory('android') - .childDirectory('app') - .childDirectory('src') - .childDirectory('test') - .existsSync() || - d.parent - .childDirectory('android') - .childDirectory('src') - .childDirectory('test') - .existsSync())); - - if (examplesWithTests.isEmpty) { - return PackageResult.skip('No Java unit tests.'); - } - - final List errors = []; - for (final Directory example in examplesWithTests) { - final String exampleName = getRelativePosixPath(example, from: package); - print('\nRUNNING JAVA TESTS for $exampleName'); - - final Directory androidDirectory = example.childDirectory('android'); - final File gradleFile = androidDirectory.childFile(_gradleWrapper); - if (!gradleFile.existsSync()) { - printError('ERROR: Run "flutter build apk" on $exampleName, or run ' - 'this tool\'s "build-examples --apk" command, ' - 'before executing tests.'); - errors.add('$exampleName has not been built.'); - continue; - } - - final int exitCode = await processRunner.runAndStream( - gradleFile.path, ['testDebugUnitTest', '--info'], - workingDir: androidDirectory); - if (exitCode != 0) { - errors.add('$exampleName tests failed.'); - } - } - return errors.isEmpty - ? PackageResult.success() - : PackageResult.fail(errors); - } -} diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index ef1a18ab15b2..6001c5df7f0a 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -15,17 +15,16 @@ import 'create_all_plugins_app_command.dart'; import 'drive_examples_command.dart'; import 'firebase_test_lab_command.dart'; import 'format_command.dart'; -import 'java_test_command.dart'; import 'license_check_command.dart'; import 'lint_podspecs_command.dart'; import 'list_command.dart'; +import 'native_test_command.dart'; import 'publish_check_command.dart'; import 'publish_plugin_command.dart'; import 'pubspec_check_command.dart'; import 'test_command.dart'; import 'version_check_command.dart'; import 'xcode_analyze_command.dart'; -import 'xctest_command.dart'; void main(List args) { const FileSystem fileSystem = LocalFileSystem(); @@ -51,17 +50,16 @@ void main(List args) { ..addCommand(DriveExamplesCommand(packagesDir)) ..addCommand(FirebaseTestLabCommand(packagesDir)) ..addCommand(FormatCommand(packagesDir)) - ..addCommand(JavaTestCommand(packagesDir)) ..addCommand(LicenseCheckCommand(packagesDir)) ..addCommand(LintPodspecsCommand(packagesDir)) ..addCommand(ListCommand(packagesDir)) + ..addCommand(NativeTestCommand(packagesDir)) ..addCommand(PublishCheckCommand(packagesDir)) ..addCommand(PublishPluginCommand(packagesDir)) ..addCommand(PubspecCheckCommand(packagesDir)) ..addCommand(TestCommand(packagesDir)) ..addCommand(VersionCheckCommand(packagesDir)) - ..addCommand(XcodeAnalyzeCommand(packagesDir)) - ..addCommand(XCTestCommand(packagesDir)); + ..addCommand(XcodeAnalyzeCommand(packagesDir)); commandRunner.run(args).catchError((Object e) { final ToolExit toolExit = e as ToolExit; diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart new file mode 100644 index 000000000000..73a435d83e1d --- /dev/null +++ b/script/tool/lib/src/native_test_command.dart @@ -0,0 +1,377 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:platform/platform.dart'; + +import 'common/core.dart'; +import 'common/package_looping_command.dart'; +import 'common/plugin_utils.dart'; +import 'common/process_runner.dart'; +import 'common/xcode.dart'; + +const String _unitTestFlag = 'unit'; +const String _integrationTestFlag = 'integration'; + +const String _iosDestinationFlag = 'ios-destination'; + +const int _exitNoIosSimulators = 3; + +/// The command to run native tests for plugins: +/// - iOS and macOS: XCTests (XCUnitTest and XCUITest) in plugins. +class NativeTestCommand extends PackageLoopingCommand { + /// Creates an instance of the test command. + NativeTestCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + }) : _xcode = Xcode(processRunner: processRunner, log: true), + super(packagesDir, processRunner: processRunner, platform: platform) { + argParser.addOption( + _iosDestinationFlag, + help: 'Specify the destination when running iOS tests.\n' + 'This is passed to the `-destination` argument in the xcodebuild command.\n' + 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT ' + 'for details on how to specify the destination.', + ); + argParser.addFlag(kPlatformAndroid, help: 'Runs Android tests'); + argParser.addFlag(kPlatformIos, help: 'Runs iOS tests'); + argParser.addFlag(kPlatformMacos, help: 'Runs macOS tests'); + + // By default, both unit tests and integration tests are run, but provide + // flags to disable one or the other. + argParser.addFlag(_unitTestFlag, + help: 'Runs native unit tests', defaultsTo: true); + argParser.addFlag(_integrationTestFlag, + help: 'Runs native integration (UI) tests', defaultsTo: true); + } + + static const String _gradleWrapper = 'gradlew'; + + // The device destination flags for iOS tests. + List _iosDestinationFlags = []; + + final Xcode _xcode; + + @override + final String name = 'native-test'; + + @override + final String description = ''' +Runs native unit tests and native integration tests. + +Currently supported platforms: +- Android (unit tests only) +- iOS: requires 'xcrun' to be in your path. +- macOS: requires 'xcrun' to be in your path. + +The example app(s) must be built for all targeted platforms before running +this command. +'''; + + Map _platforms = {}; + + List _requestedPlatforms = []; + + @override + Future initializeRun() async { + _platforms = { + kPlatformAndroid: _PlatformDetails('Android', _testAndroid), + kPlatformIos: _PlatformDetails('iOS', _testIos), + kPlatformMacos: _PlatformDetails('macOS', _testMacOS), + }; + _requestedPlatforms = _platforms.keys + .where((String platform) => getBoolArg(platform)) + .toList(); + _requestedPlatforms.sort(); + + if (_requestedPlatforms.isEmpty) { + printError('At least one platform flag must be provided.'); + throw ToolExit(exitInvalidArguments); + } + + if (!(getBoolArg(_unitTestFlag) || getBoolArg(_integrationTestFlag))) { + printError('At least one test type must be enabled.'); + throw ToolExit(exitInvalidArguments); + } + + if (getBoolArg(kPlatformAndroid) && getBoolArg(_integrationTestFlag)) { + logWarning('This command currently only supports unit tests for Android. ' + 'See https://github.com/flutter/flutter/issues/86490.'); + } + + // iOS-specific run-level state. + if (_requestedPlatforms.contains('ios')) { + String destination = getStringArg(_iosDestinationFlag); + if (destination.isEmpty) { + final String? simulatorId = + await _xcode.findBestAvailableIphoneSimulator(); + if (simulatorId == null) { + printError('Cannot find any available iOS simulators.'); + throw ToolExit(_exitNoIosSimulators); + } + destination = 'id=$simulatorId'; + } + _iosDestinationFlags = [ + '-destination', + destination, + ]; + } + } + + @override + Future runForPackage(Directory package) async { + final List testPlatforms = []; + for (final String platform in _requestedPlatforms) { + if (pluginSupportsPlatform(platform, package, + requiredMode: PlatformSupport.inline)) { + testPlatforms.add(platform); + } else { + print('No implementation for ${_platforms[platform]!.label}.'); + } + } + + if (testPlatforms.isEmpty) { + return PackageResult.skip('Not implemented for target platform(s).'); + } + + final _TestMode mode = _TestMode( + unit: getBoolArg(_unitTestFlag), + integration: getBoolArg(_integrationTestFlag), + ); + + bool ranTests = false; + bool failed = false; + final List failureMessages = []; + for (final String platform in testPlatforms) { + final _PlatformDetails platformInfo = _platforms[platform]!; + print('Running tests for ${platformInfo.label}...'); + print('----------------------------------------'); + final _PlatformResult result = + await platformInfo.testFunction(package, mode); + ranTests |= result.state != RunState.skipped; + if (result.state == RunState.failed) { + failed = true; + + final String? error = result.error; + // Only provide the failing platforms in the failure details if testing + // multiple platforms, otherwise it's just noise. + if (_requestedPlatforms.length > 1) { + failureMessages.add(error != null + ? '${platformInfo.label}: $error' + : platformInfo.label); + } else if (error != null) { + // If there's only one platform, only provide error details in the + // summary if the platform returned a message. + failureMessages.add(error); + } + } + } + + if (!ranTests) { + return PackageResult.skip('No tests found.'); + } + return failed + ? PackageResult.fail(failureMessages) + : PackageResult.success(); + } + + Future<_PlatformResult> _testAndroid(Directory plugin, _TestMode mode) async { + final List examplesWithTests = []; + for (final Directory example in getExamplesForPlugin(plugin)) { + if (!isFlutterPackage(example)) { + continue; + } + if (example + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('test') + .existsSync() || + example.parent + .childDirectory('android') + .childDirectory('src') + .childDirectory('test') + .existsSync()) { + examplesWithTests.add(example); + } else { + _printNoExampleTestsMessage(example, 'Android'); + } + } + + if (examplesWithTests.isEmpty) { + return _PlatformResult(RunState.skipped); + } + + bool failed = false; + bool hasMissingBuild = false; + for (final Directory example in examplesWithTests) { + final String exampleName = getPackageDescription(example); + _printRunningExampleTestsMessage(example, 'Android'); + + final Directory androidDirectory = example.childDirectory('android'); + final File gradleFile = androidDirectory.childFile(_gradleWrapper); + if (!gradleFile.existsSync()) { + printError('ERROR: Run "flutter build apk" on $exampleName, or run ' + 'this tool\'s "build-examples --apk" command, ' + 'before executing tests.'); + failed = true; + hasMissingBuild = true; + continue; + } + + final int exitCode = await processRunner.runAndStream( + gradleFile.path, ['testDebugUnitTest', '--info'], + workingDir: androidDirectory); + if (exitCode != 0) { + printError('$exampleName tests failed.'); + failed = true; + } + } + return _PlatformResult(failed ? RunState.failed : RunState.succeeded, + error: + hasMissingBuild ? 'Examples must be built before testing.' : null); + } + + Future<_PlatformResult> _testIos(Directory plugin, _TestMode mode) { + return _runXcodeTests(plugin, 'iOS', mode, + extraFlags: _iosDestinationFlags); + } + + Future<_PlatformResult> _testMacOS(Directory plugin, _TestMode mode) { + return _runXcodeTests(plugin, 'macOS', mode); + } + + /// Runs all applicable tests for [plugin], printing status and returning + /// the test result. + /// + /// The tests targets must be added to the Xcode project of the example app, + /// usually at "example/{ios,macos}/Runner.xcworkspace". + Future<_PlatformResult> _runXcodeTests( + Directory plugin, + String platform, + _TestMode mode, { + List extraFlags = const [], + }) async { + String? testTarget; + if (mode.unitOnly) { + testTarget = 'RunnerTests'; + } else if (mode.integrationOnly) { + testTarget = 'RunnerUITests'; + } + + // Assume skipped until at least one test has run. + RunState overallResult = RunState.skipped; + for (final Directory example in getExamplesForPlugin(plugin)) { + final String exampleName = getPackageDescription(example); + + if (testTarget != null) { + final Directory project = example + .childDirectory(platform.toLowerCase()) + .childDirectory('Runner.xcodeproj'); + final bool? hasTarget = + await _xcode.projectHasTarget(project, testTarget); + if (hasTarget == null) { + printError('Unable to check targets for $exampleName.'); + overallResult = RunState.failed; + continue; + } else if (!hasTarget) { + print('No "$testTarget" target in $exampleName; skipping.'); + continue; + } + } + + _printRunningExampleTestsMessage(example, platform); + final int exitCode = await _xcode.runXcodeBuild( + example, + actions: ['test'], + workspace: '${platform.toLowerCase()}/Runner.xcworkspace', + scheme: 'Runner', + configuration: 'Debug', + extraFlags: [ + if (testTarget != null) '-only-testing:$testTarget', + ...extraFlags, + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + ); + + // The exit code from 'xcodebuild test' when there are no tests. + const int _xcodebuildNoTestExitCode = 66; + switch (exitCode) { + case _xcodebuildNoTestExitCode: + _printNoExampleTestsMessage(example, platform); + continue; + case 0: + printSuccess('Successfully ran $platform xctest for $exampleName'); + // If this is the first test, assume success until something fails. + if (overallResult == RunState.skipped) { + overallResult = RunState.succeeded; + } + break; + default: + // Any failure means a failure overall. + overallResult = RunState.failed; + break; + } + } + return _PlatformResult(overallResult); + } + + /// Prints a standard format message indicating that [platform] tests for + /// [plugin]'s [example] are about to be run. + void _printRunningExampleTestsMessage(Directory example, String platform) { + print('Running $platform tests for ${getPackageDescription(example)}...'); + } + + /// Prints a standard format message indicating that no tests were found for + /// [plugin]'s [example] for [platform]. + void _printNoExampleTestsMessage(Directory example, String platform) { + print('No $platform tests found for ${getPackageDescription(example)}'); + } +} + +// The type for a function that takes a plugin directory and runs its native +// tests for a specific platform. +typedef _TestFunction = Future<_PlatformResult> Function(Directory, _TestMode); + +/// A collection of information related to a specific platform. +class _PlatformDetails { + const _PlatformDetails( + this.label, + this.testFunction, + ); + + /// The name to use in output. + final String label; + + /// The function to call to run tests. + final _TestFunction testFunction; +} + +/// Enabled state for different test types. +class _TestMode { + const _TestMode({required this.unit, required this.integration}); + + final bool unit; + final bool integration; + + bool get integrationOnly => integration && !unit; + bool get unitOnly => unit && !integration; +} + +/// The result of running a single platform's tests. +class _PlatformResult { + _PlatformResult(this.state, {this.error}); + + /// The overall state of the platform's tests. This should be: + /// - failed if any tests failed. + /// - succeeded if at least one test ran, and all tests passed. + /// - skipped if no tests ran. + final RunState state; + + /// An optional error string to include in the summary for this platform. + /// + /// Ignored unless [state] is `failed`. + final String? error; +} diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart deleted file mode 100644 index 44fc3a87d540..000000000000 --- a/script/tool/lib/src/xctest_command.dart +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2013 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 'package:file/file.dart'; -import 'package:platform/platform.dart'; - -import 'common/core.dart'; -import 'common/package_looping_command.dart'; -import 'common/plugin_utils.dart'; -import 'common/process_runner.dart'; -import 'common/xcode.dart'; - -const String _iosDestinationFlag = 'ios-destination'; -const String _testTargetFlag = 'test-target'; - -// The exit code from 'xcodebuild test' when there are no tests. -const int _xcodebuildNoTestExitCode = 66; - -const int _exitNoSimulators = 3; - -/// The command to run XCTests (XCUnitTest and XCUITest) in plugins. -/// The tests target have to be added to the Xcode project of the example app, -/// usually at "example/{ios,macos}/Runner.xcworkspace". -class XCTestCommand extends PackageLoopingCommand { - /// Creates an instance of the test command. - XCTestCommand( - Directory packagesDir, { - ProcessRunner processRunner = const ProcessRunner(), - Platform platform = const LocalPlatform(), - }) : _xcode = Xcode(processRunner: processRunner, log: true), - super(packagesDir, processRunner: processRunner, platform: platform) { - argParser.addOption( - _iosDestinationFlag, - help: - 'Specify the destination when running the test, used for -destination flag for xcodebuild command.\n' - 'this is passed to the `-destination` argument in xcodebuild command.\n' - 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.', - ); - argParser.addOption( - _testTargetFlag, - help: - 'Limits the tests to a specific target (e.g., RunnerTests or RunnerUITests)', - ); - argParser.addFlag(kPlatformIos, help: 'Runs the iOS tests'); - argParser.addFlag(kPlatformMacos, help: 'Runs the macOS tests'); - } - - // The device destination flags for iOS tests. - List _iosDestinationFlags = []; - - final Xcode _xcode; - - @override - final String name = 'xctest'; - - @override - final String description = - 'Runs the xctests in the iOS and/or macOS example apps.\n\n' - 'This command requires "flutter" and "xcrun" to be in your path.'; - - @override - Future initializeRun() async { - final bool shouldTestIos = getBoolArg(kPlatformIos); - final bool shouldTestMacos = getBoolArg(kPlatformMacos); - - if (!(shouldTestIos || shouldTestMacos)) { - printError('At least one platform flag must be provided.'); - throw ToolExit(exitInvalidArguments); - } - - if (shouldTestIos) { - String destination = getStringArg(_iosDestinationFlag); - if (destination.isEmpty) { - final String? simulatorId = - await _xcode.findBestAvailableIphoneSimulator(); - if (simulatorId == null) { - printError('Cannot find any available simulators, tests failed'); - throw ToolExit(_exitNoSimulators); - } - destination = 'id=$simulatorId'; - } - _iosDestinationFlags = [ - '-destination', - destination, - ]; - } - } - - @override - Future runForPackage(Directory package) async { - final bool testIos = getBoolArg(kPlatformIos) && - pluginSupportsPlatform(kPlatformIos, package, - requiredMode: PlatformSupport.inline); - final bool testMacos = getBoolArg(kPlatformMacos) && - pluginSupportsPlatform(kPlatformMacos, package, - requiredMode: PlatformSupport.inline); - - final bool multiplePlatformsRequested = - getBoolArg(kPlatformIos) && getBoolArg(kPlatformMacos); - if (!(testIos || testMacos)) { - String description; - if (multiplePlatformsRequested) { - description = 'Neither iOS nor macOS is'; - } else if (getBoolArg(kPlatformIos)) { - description = 'iOS is not'; - } else { - description = 'macOS is not'; - } - return PackageResult.skip( - '$description implemented by this plugin package.'); - } - - if (multiplePlatformsRequested && (!testIos || !testMacos)) { - print('Only running for ${testIos ? 'iOS' : 'macOS'}\n'); - } - - final List failures = []; - bool ranTests = false; - if (testIos) { - final RunState result = await _testPlugin(package, 'iOS', - extraXcrunFlags: _iosDestinationFlags); - ranTests |= result != RunState.skipped; - if (result == RunState.failed) { - failures.add('iOS'); - } - } - if (testMacos) { - final RunState result = await _testPlugin(package, 'macOS'); - ranTests |= result != RunState.skipped; - if (result == RunState.failed) { - failures.add('macOS'); - } - } - - if (!ranTests) { - return PackageResult.skip('No tests found.'); - } - // Only provide the failing platform in the failure details if testing - // multiple platforms, otherwise it's just noise. - return failures.isEmpty - ? PackageResult.success() - : PackageResult.fail( - multiplePlatformsRequested ? failures : []); - } - - /// Runs all applicable tests for [plugin], printing status and returning - /// the test result. - Future _testPlugin( - Directory plugin, - String platform, { - List extraXcrunFlags = const [], - }) async { - final String testTarget = getStringArg(_testTargetFlag); - - // Assume skipped until at least one test has run. - RunState overallResult = RunState.skipped; - for (final Directory example in getExamplesForPlugin(plugin)) { - final String examplePath = - getRelativePosixPath(example, from: plugin.parent); - - if (testTarget.isNotEmpty) { - final Directory project = example - .childDirectory(platform.toLowerCase()) - .childDirectory('Runner.xcodeproj'); - final bool? hasTarget = - await _xcode.projectHasTarget(project, testTarget); - if (hasTarget == null) { - printError('Unable to check targets for $examplePath.'); - overallResult = RunState.failed; - continue; - } else if (!hasTarget) { - print('No "$testTarget" target in $examplePath; skipping.'); - continue; - } - } - - print('Running $platform tests for $examplePath...'); - final int exitCode = await _xcode.runXcodeBuild( - example, - actions: ['test'], - workspace: '${platform.toLowerCase()}/Runner.xcworkspace', - scheme: 'Runner', - configuration: 'Debug', - extraFlags: [ - if (testTarget.isNotEmpty) '-only-testing:$testTarget', - ...extraXcrunFlags, - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - ); - - switch (exitCode) { - case _xcodebuildNoTestExitCode: - print('No tests found for $examplePath'); - continue; - case 0: - printSuccess('Successfully ran $platform xctest for $examplePath'); - // If this is the first test, assume success until something fails. - if (overallResult == RunState.skipped) { - overallResult = RunState.succeeded; - } - break; - default: - // Any failure means a failure overall. - overallResult = RunState.failed; - break; - } - } - return overallResult; - } -} diff --git a/script/tool/test/java_test_command_test.dart b/script/tool/test/java_test_command_test.dart deleted file mode 100644 index 13e0e7fc0f40..000000000000 --- a/script/tool/test/java_test_command_test.dart +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright 2013 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:io' as io; - -import 'package:args/command_runner.dart'; -import 'package:file/file.dart'; -import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common/core.dart'; -import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; -import 'package:flutter_plugin_tools/src/java_test_command.dart'; -import 'package:test/test.dart'; - -import 'mocks.dart'; -import 'util.dart'; - -void main() { - group('$JavaTestCommand', () { - late FileSystem fileSystem; - late MockPlatform mockPlatform; - late Directory packagesDir; - late CommandRunner runner; - late RecordingProcessRunner processRunner; - - setUp(() { - fileSystem = MemoryFileSystem(); - mockPlatform = MockPlatform(); - packagesDir = createPackagesDirectory(fileSystem: fileSystem); - processRunner = RecordingProcessRunner(); - final JavaTestCommand command = JavaTestCommand( - packagesDir, - processRunner: processRunner, - platform: mockPlatform, - ); - - runner = - CommandRunner('java_test_test', 'Test for $JavaTestCommand'); - runner.addCommand(command); - }); - - test('Should run Java tests in Android implementation folder', () async { - final Directory plugin = createFakePlugin( - 'plugin1', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }, - extraFiles: [ - 'example/android/gradlew', - 'android/src/test/example_test.java', - ], - ); - - await runCapturingPrint(runner, ['java-test']); - - final Directory androidFolder = - plugin.childDirectory('example').childDirectory('android'); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - androidFolder.childFile('gradlew').path, - const ['testDebugUnitTest', '--info'], - androidFolder.path, - ), - ]), - ); - }); - - test('Should run Java tests in example folder', () async { - final Directory plugin = createFakePlugin( - 'plugin1', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }, - extraFiles: [ - 'example/android/gradlew', - 'example/android/app/src/test/example_test.java', - ], - ); - - await runCapturingPrint(runner, ['java-test']); - - final Directory androidFolder = - plugin.childDirectory('example').childDirectory('android'); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - androidFolder.childFile('gradlew').path, - const ['testDebugUnitTest', '--info'], - androidFolder.path, - ), - ]), - ); - }); - - test('fails when the app needs to be built', () async { - createFakePlugin( - 'plugin1', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }, - extraFiles: [ - 'example/android/app/src/test/example_test.java', - ], - ); - - Error? commandError; - final List output = await runCapturingPrint( - runner, ['java-test'], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - - expect( - output, - containsAllInOrder([ - contains('ERROR: Run "flutter build apk" on example'), - contains('plugin1:\n' - ' example has not been built.') - ]), - ); - }); - - test('fails when a test fails', () async { - final Directory pluginDir = createFakePlugin( - 'plugin1', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline - }, - extraFiles: [ - 'example/android/gradlew', - 'example/android/app/src/test/example_test.java', - ], - ); - - final String gradlewPath = pluginDir - .childDirectory('example') - .childDirectory('android') - .childFile('gradlew') - .path; - processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.failing() - ]; - - Error? commandError; - final List output = await runCapturingPrint( - runner, ['java-test'], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - - expect( - output, - containsAllInOrder([ - contains('plugin1:\n' - ' example tests failed.') - ]), - ); - }); - - test('Skips when running no tests', () async { - createFakePlugin( - 'plugin1', - packagesDir, - ); - - final List output = - await runCapturingPrint(runner, ['java-test']); - - expect( - output, - containsAllInOrder( - [contains('SKIPPING: No Java unit tests.')]), - ); - }); - }); -} diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart new file mode 100644 index 000000000000..ca28a6cff0e7 --- /dev/null +++ b/script/tool/test/native_test_command_test.dart @@ -0,0 +1,1071 @@ +// Copyright 2013 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:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:flutter_plugin_tools/src/native_test_command.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +final Map _kDeviceListMap = { + 'runtimes': >[ + { + 'bundlePath': + '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime', + 'buildversion': '17L255', + 'runtimeRoot': + '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime/Contents/Resources/RuntimeRoot', + 'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-13-4', + 'version': '13.4', + 'isAvailable': true, + 'name': 'iOS 13.4' + }, + ], + 'devices': { + 'com.apple.CoreSimulator.SimRuntime.iOS-13-4': >[ + { + 'dataPath': + '/Users/xxx/Library/Developer/CoreSimulator/Devices/1E76A0FD-38AC-4537-A989-EA639D7D012A/data', + 'logPath': + '/Users/xxx/Library/Logs/CoreSimulator/1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'udid': '1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'isAvailable': true, + 'deviceTypeIdentifier': + 'com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus', + 'state': 'Shutdown', + 'name': 'iPhone 8 Plus' + } + ] + } +}; + +// TODO(stuartmorgan): Rework these tests to use a mock Xcode instead of +// doing all the process mocking and validation. +void main() { + const String _kDestination = '--ios-destination'; + + group('test native_test_command', () { + late FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(isMacOS: true); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); + final NativeTestCommand command = NativeTestCommand(packagesDir, + processRunner: processRunner, platform: mockPlatform); + + runner = CommandRunner( + 'native_test_command', 'Test for native_test_command'); + runner.addCommand(command); + }); + + test('fails if no platforms are provided', () async { + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('At least one platform flag must be provided.'), + ]), + ); + }); + + test('fails if all test types are disabled', () async { + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-unit', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('At least one test type must be enabled.'), + ]), + ); + }); + + test('reports skips with no tests', () async { + final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + // Exit code 66 from testing indicates no tests. + final MockProcess noTestsProcessResult = MockProcess(); + noTestsProcessResult.exitCodeCompleter.complete(66); + processRunner.mockProcessesForExecutable['xcrun'] = [ + noTestsProcessResult, + ]; + final List output = + await runCapturingPrint(runner, ['native-test', '--macos']); + + expect(output, contains(contains('No tests found.'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + group('iOS', () { + test('skip if iOS is not supported', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final List output = await runCapturingPrint(runner, + ['native-test', '--ios', _kDestination, 'foo_destination']); + expect( + output, + containsAllInOrder([ + contains('No implementation for iOS.'), + contains('SKIPPING: Not implemented for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if iOS is implemented in a federated package', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: PlatformSupport.federated + }); + + final List output = await runCapturingPrint(runner, + ['native-test', '--ios', _kDestination, 'foo_destination']); + expect( + output, + containsAllInOrder([ + contains('No implementation for iOS.'), + contains('SKIPPING: Not implemented for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('running with correct destination', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--ios', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Successfully ran iOS xctest for plugin/example') + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'foo_destination', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('Not specifying --ios-destination assigns an available simulator', + () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = jsonEncode(_kDeviceListMap); + await runCapturingPrint(runner, ['native-test', '--ios']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall( + 'xcrun', + [ + 'simctl', + 'list', + 'devices', + 'runtimes', + 'available', + '--json', + ], + null), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + }); + + group('macOS', () { + test('skip if macOS is not supported', () async { + createFakePlugin('plugin', packagesDir); + + final List output = + await runCapturingPrint(runner, ['native-test', '--macos']); + + expect( + output, + containsAllInOrder([ + contains('No implementation for macOS.'), + contains('SKIPPING: Not implemented for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if macOS is implemented in a federated package', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.federated, + }); + + final List output = + await runCapturingPrint(runner, ['native-test', '--macos']); + + expect( + output, + containsAllInOrder([ + contains('No implementation for macOS.'), + contains('SKIPPING: Not implemented for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('runs for macOS plugin', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + }); + + group('Android', () { + test('runs Java tests in Android implementation folder', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'android/src/test/example_test.java', + ], + ); + + await runCapturingPrint(runner, ['native-test', '--android']); + + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const ['testDebugUnitTest', '--info'], + androidFolder.path, + ), + ]), + ); + }); + + test('runs Java tests in example folder', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + ], + ); + + await runCapturingPrint(runner, ['native-test', '--android']); + + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const ['testDebugUnitTest', '--info'], + androidFolder.path, + ), + ]), + ); + }); + + test('fails when the app needs to be built', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/app/src/test/example_test.java', + ], + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('ERROR: Run "flutter build apk" on plugin/example'), + contains('plugin:\n' + ' Examples must be built before testing.') + ]), + ); + }); + + test('fails when a test fails', () async { + final Directory pluginDir = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + ], + ); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('plugin/example tests failed.'), + contains('The following packages had errors:'), + contains('plugin') + ]), + ); + }); + + test('skips if Android is not supported', () async { + createFakePlugin( + 'plugin', + packagesDir, + ); + + final List output = await runCapturingPrint( + runner, ['native-test', '--android']); + + expect( + output, + containsAllInOrder([ + contains('No implementation for Android.'), + contains('SKIPPING: Not implemented for target platform(s).'), + ]), + ); + }); + + test('skips when running no tests', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + ); + + final List output = await runCapturingPrint( + runner, ['native-test', '--android']); + + expect( + output, + containsAllInOrder([ + contains('No Android tests found for plugin/example'), + contains('SKIPPING: No tests found.'), + ]), + ); + }); + }); + + // Tests behaviors of implementation that is shared between iOS and macOS. + group('iOS/macOS', () { + test('fails if xcrun fails', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = + await runCapturingPrint(runner, ['native-test', '--macos'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages had errors:'), + contains(' plugin'), + ]), + ); + }); + + test('honors unit-only', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = + '{"project":{"targets":["RunnerTests", "RunnerUITests"]}}'; + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-integration', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + // --no-integration should translate to '-only-testing:RunnerTests'. + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-only-testing:RunnerTests', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('honors integration-only', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.succeeding(); + processRunner.resultStdout = + '{"project":{"targets":["RunnerTests", "RunnerUITests"]}}'; + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-unit', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + // --no-unit should translate to '-only-testing:RunnerUITests'. + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-only-testing:RunnerUITests', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('skips when the requested target is not present', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.succeeding(); + // Simulate a project with unit tests but no integration tests... + processRunner.resultStdout = '{"project":{"targets":["RunnerTests"]}}'; + // ... then try to run only integration tests. + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-unit', + ]); + + expect( + output, + containsAllInOrder([ + contains( + 'No "RunnerUITests" target in plugin/example; skipping.'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ])); + }); + + test('fails if unable to check for requested target', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.processToReturn = MockProcess.failing(); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to check targets for plugin/example.'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + pluginExampleDirectory + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .path, + ], + null), + ])); + }); + }); + + group('multiplatform', () { + test('runs all platfroms when supported', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/android/gradlew', + 'android/src/test/example_test.java', + ], + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + kPlatformMacos: PlatformSupport.inline, + }, + ); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + final Directory androidFolder = + pluginExampleDirectory.childDirectory('android'); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--android', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAll([ + contains('Running Android tests for plugin/example'), + contains('Successfully ran iOS xctest for plugin/example'), + contains('Successfully ran macOS xctest for plugin/example'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const ['testDebugUnitTest', '--info'], + androidFolder.path), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'foo_destination', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('runs only macOS for a macOS plugin', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: PlatformSupport.inline, + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('No implementation for iOS.'), + contains('Successfully ran macOS xctest for plugin/example'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'macos/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('runs only iOS for a iOS plugin', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { + kPlatformIos: PlatformSupport.inline + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('No implementation for macOS.'), + contains('Successfully ran iOS xctest for plugin/example') + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'xcrun', + const [ + 'xcodebuild', + 'test', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + '-destination', + 'foo_destination', + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + pluginExampleDirectory.path), + ])); + }); + + test('skips when nothing is supported', () async { + createFakePlugin('plugin', packagesDir); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--android', + '--ios', + '--macos', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('No implementation for Android.'), + contains('No implementation for iOS.'), + contains('No implementation for macOS.'), + contains('SKIPPING: Not implemented for target platform(s).'), + ])); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('failing one platform does not stop the tests', () async { + final Directory pluginDir = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + ], + ); + + // Simulate failing Android, but not iOS. + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--android', + '--ios', + '--ios-destination', + 'foo_destination', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('Running tests for Android...'), + contains('plugin/example tests failed.'), + contains('Running tests for iOS...'), + contains('Successfully ran iOS xctest for plugin/example'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' Android') + ]), + ); + }); + + test('failing multiple platforms reports multiple failures', () async { + final Directory pluginDir = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline, + kPlatformIos: PlatformSupport.inline, + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + ], + ); + + // Simulate failing Android. + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess.failing() + ]; + // Simulate failing Android. + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess.failing() + ]; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--android', + '--ios', + '--ios-destination', + 'foo_destination', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('Running tests for Android...'), + contains('Running tests for iOS...'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' Android\n' + ' iOS') + ]), + ); + }); + }); + }); +} diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart deleted file mode 100644 index 324dea0e71ef..000000000000 --- a/script/tool/test/xctest_command_test.dart +++ /dev/null @@ -1,705 +0,0 @@ -// Copyright 2013 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:args/command_runner.dart'; -import 'package:file/file.dart'; -import 'package:file/memory.dart'; -import 'package:flutter_plugin_tools/src/common/core.dart'; -import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; -import 'package:flutter_plugin_tools/src/xctest_command.dart'; -import 'package:test/test.dart'; - -import 'mocks.dart'; -import 'util.dart'; - -final Map _kDeviceListMap = { - 'runtimes': >[ - { - 'bundlePath': - '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime', - 'buildversion': '17L255', - 'runtimeRoot': - '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime/Contents/Resources/RuntimeRoot', - 'identifier': 'com.apple.CoreSimulator.SimRuntime.iOS-13-4', - 'version': '13.4', - 'isAvailable': true, - 'name': 'iOS 13.4' - }, - ], - 'devices': { - 'com.apple.CoreSimulator.SimRuntime.iOS-13-4': >[ - { - 'dataPath': - '/Users/xxx/Library/Developer/CoreSimulator/Devices/1E76A0FD-38AC-4537-A989-EA639D7D012A/data', - 'logPath': - '/Users/xxx/Library/Logs/CoreSimulator/1E76A0FD-38AC-4537-A989-EA639D7D012A', - 'udid': '1E76A0FD-38AC-4537-A989-EA639D7D012A', - 'isAvailable': true, - 'deviceTypeIdentifier': - 'com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus', - 'state': 'Shutdown', - 'name': 'iPhone 8 Plus' - } - ] - } -}; - -// TODO(stuartmorgan): Rework these tests to use a mock Xcode instead of -// doing all the process mocking and validation. -void main() { - const String _kDestination = '--ios-destination'; - - group('test xctest_command', () { - late FileSystem fileSystem; - late MockPlatform mockPlatform; - late Directory packagesDir; - late CommandRunner runner; - late RecordingProcessRunner processRunner; - - setUp(() { - fileSystem = MemoryFileSystem(); - mockPlatform = MockPlatform(isMacOS: true); - packagesDir = createPackagesDirectory(fileSystem: fileSystem); - processRunner = RecordingProcessRunner(); - final XCTestCommand command = XCTestCommand(packagesDir, - processRunner: processRunner, platform: mockPlatform); - - runner = CommandRunner('xctest_command', 'Test for xctest_command'); - runner.addCommand(command); - }); - - test('Fails if no platforms are provided', () async { - Error? commandError; - final List output = await runCapturingPrint( - runner, ['xctest'], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - expect( - output, - containsAllInOrder([ - contains('At least one platform flag must be provided'), - ]), - ); - }); - - test('allows target filtering', () async { - final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = '{"project":{"targets":["RunnerTests"]}}'; - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--macos', - '--test-target=RunnerTests', - ]); - - expect( - output, - contains( - contains('Successfully ran macOS xctest for plugin/example'))); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-only-testing:RunnerTests', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('skips when the requested target is not present', () async { - final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = '{"project":{"targets":["Runner"]}}'; - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--macos', - '--test-target=RunnerTests', - ]); - - expect( - output, - containsAllInOrder([ - contains('No "RunnerTests" target in plugin/example; skipping.'), - ])); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), - ])); - }); - - test('fails if unable to check for requested target', () async { - final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - processRunner.processToReturn = MockProcess.failing(); - - Error? commandError; - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--macos', - '--test-target=RunnerTests', - ], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - expect( - output, - containsAllInOrder([ - contains('Unable to check targets for plugin/example.'), - ]), - ); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), - ])); - }); - - test('reports skips with no tests', () async { - final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - // Exit code 66 from testing indicates no tests. - final MockProcess noTestsProcessResult = MockProcess(); - noTestsProcessResult.exitCodeCompleter.complete(66); - processRunner.mockProcessesForExecutable['xcrun'] = [ - noTestsProcessResult, - ]; - final List output = - await runCapturingPrint(runner, ['xctest', '--macos']); - - expect(output, contains(contains('No tests found.'))); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - group('iOS', () { - test('skip if iOS is not supported', () async { - createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final List output = await runCapturingPrint(runner, - ['xctest', '--ios', _kDestination, 'foo_destination']); - expect( - output, - contains( - contains('iOS is not implemented by this plugin package.'))); - expect(processRunner.recordedCalls, orderedEquals([])); - }); - - test('skip if iOS is implemented in a federated package', () async { - createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.federated - }); - - final List output = await runCapturingPrint(runner, - ['xctest', '--ios', _kDestination, 'foo_destination']); - expect( - output, - contains( - contains('iOS is not implemented by this plugin package.'))); - expect(processRunner.recordedCalls, orderedEquals([])); - }); - - test('running with correct destination', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline - }); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--ios', - _kDestination, - 'foo_destination', - ]); - - expect( - output, - containsAllInOrder([ - contains('Running for plugin'), - contains('Successfully ran iOS xctest for plugin/example') - ])); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('Not specifying --ios-destination assigns an available simulator', - () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline - }); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = jsonEncode(_kDeviceListMap); - await runCapturingPrint(runner, ['xctest', '--ios']); - - expect( - processRunner.recordedCalls, - orderedEquals([ - const ProcessCall( - 'xcrun', - [ - 'simctl', - 'list', - 'devices', - 'runtimes', - 'available', - '--json', - ], - null), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('fails if xcrun fails', () async { - createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline - }); - - processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing() - ]; - - Error? commandError; - final List output = await runCapturingPrint( - runner, - [ - 'xctest', - '--ios', - _kDestination, - 'foo_destination', - ], - errorHandler: (Error e) { - commandError = e; - }, - ); - - expect(commandError, isA()); - expect( - output, - containsAllInOrder([ - contains('The following packages had errors:'), - contains(' plugin'), - ])); - }); - }); - - group('macOS', () { - test('skip if macOS is not supported', () async { - createFakePlugin('plugin', packagesDir); - - final List output = - await runCapturingPrint(runner, ['xctest', '--macos']); - expect( - output, - contains( - contains('macOS is not implemented by this plugin package.'))); - expect(processRunner.recordedCalls, orderedEquals([])); - }); - - test('skip if macOS is implemented in a federated package', () async { - createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.federated, - }); - - final List output = - await runCapturingPrint(runner, ['xctest', '--macos']); - expect( - output, - contains( - contains('macOS is not implemented by this plugin package.'))); - expect(processRunner.recordedCalls, orderedEquals([])); - }); - - test('runs for macOS plugin', () async { - final Directory pluginDirectory1 = createFakePlugin( - 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--macos', - ]); - - expect( - output, - contains( - contains('Successfully ran macOS xctest for plugin/example'))); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('fails if xcrun fails', () async { - createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing() - ]; - - Error? commandError; - final List output = await runCapturingPrint( - runner, ['xctest', '--macos'], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - expect( - output, - containsAllInOrder([ - contains('The following packages had errors:'), - contains(' plugin'), - ]), - ); - }); - }); - - group('combined', () { - test('runs both iOS and macOS when supported', () async { - final Directory pluginDirectory1 = createFakePlugin( - 'plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline, - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--ios', - '--macos', - _kDestination, - 'foo_destination', - ]); - - expect( - output, - containsAll([ - contains('Successfully ran iOS xctest for plugin/example'), - contains('Successfully ran macOS xctest for plugin/example'), - ])); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('runs only macOS for a macOS plugin', () async { - final Directory pluginDirectory1 = createFakePlugin( - 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, - }); - - final Directory pluginExampleDirectory = - pluginDirectory1.childDirectory('example'); - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--ios', - '--macos', - _kDestination, - 'foo_destination', - ]); - - expect( - output, - containsAllInOrder([ - contains('Only running for macOS'), - contains('Successfully ran macOS xctest for plugin/example'), - ])); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('runs only iOS for a iOS plugin', () async { - final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline - }); - - final Directory pluginExampleDirectory = - pluginDirectory.childDirectory('example'); - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--ios', - '--macos', - _kDestination, - 'foo_destination', - ]); - - expect( - output, - containsAllInOrder([ - contains('Only running for iOS'), - contains('Successfully ran iOS xctest for plugin/example') - ])); - - expect( - processRunner.recordedCalls, - orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ])); - }); - - test('skips when neither are supported', () async { - createFakePlugin('plugin', packagesDir); - - final List output = await runCapturingPrint(runner, [ - 'xctest', - '--ios', - '--macos', - _kDestination, - 'foo_destination', - ]); - - expect( - output, - containsAllInOrder([ - contains( - 'SKIPPING: Neither iOS nor macOS is implemented by this plugin package.'), - ])); - - expect(processRunner.recordedCalls, orderedEquals([])); - }); - }); - }); -} From 758c55e42064875ddfc6a89373af07db26c7b733 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 22 Jul 2021 14:26:44 -0700 Subject: [PATCH 148/364] [flutter_plugin_tools] Support YAML exception lists (#4183) Currently the tool accepts `--custom-analysis` to allow a list of packages for which custom `analysis_options.yaml` are allowed, and `--exclude` to exclude a set of packages when running a command against all, or all changed, packages. This results in these exception lists being embedded into CI configuration files (e.g., .cirrus.yaml) or scripts, which makes them harder to maintain, and harder to re-use in other contexts (local runs, new CI systems). This adds support for both flags to accept paths to YAML files that contain the lists, so that they can be maintained separately, and with inline comments about the reasons things are on the lists. Also updates the CI to use this new support, eliminating those lists from `.cirrus.yaml` and `tool_runner.sh` Fixes https://github.com/flutter/flutter/issues/86799 --- .cirrus.yml | 36 ++---------- packages/e2e/analysis_options.yaml | 1 - script/configs/README.md | 8 +++ script/configs/custom_analysis.yaml | 41 ++++++++++++++ .../configs/exclude_integration_android.yaml | 22 ++++++++ script/configs/exclude_integration_ios.yaml | 6 ++ script/configs/exclude_integration_web.yaml | 4 ++ script/configs/exclude_native_macos.yaml | 3 + script/tool/CHANGELOG.md | 4 ++ script/tool/lib/src/analyze_command.dart | 21 ++++++- .../tool/lib/src/common/plugin_command.dart | 17 +++++- script/tool/test/analyze_command_test.dart | 19 +++++++ .../tool/test/common/plugin_command_test.dart | 13 +++++ script/tool_runner.sh | 55 +------------------ 14 files changed, 161 insertions(+), 89 deletions(-) delete mode 100644 packages/e2e/analysis_options.yaml create mode 100644 script/configs/README.md create mode 100644 script/configs/custom_analysis.yaml create mode 100644 script/configs/exclude_integration_android.yaml create mode 100644 script/configs/exclude_integration_ios.yaml create mode 100644 script/configs/exclude_integration_web.yaml create mode 100644 script/configs/exclude_native_macos.yaml diff --git a/.cirrus.yml b/.cirrus.yml index edefc19bd21a..54c4c3799ec3 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -72,7 +72,7 @@ task: - cd script/tool - dart analyze --fatal-infos script: - - ./script/tool_runner.sh analyze + - ./script/tool_runner.sh analyze --custom-analysis=script/configs/custom_analysis.yaml ### Android tasks ### - name: build_all_plugins_apk env: @@ -137,22 +137,6 @@ task: CHANNEL: "stable" MAPS_API_KEY: ENCRYPTED[596a9f6bca436694625ac50851dc5da6b4d34cba8025f7db5bc9465142e8cd44e15f69e3507787753accebfc4910d550] GCLOUD_FIREBASE_TESTLAB_KEY: ENCRYPTED[07586610af1fdfc894e5969f70ef2458341b9b7e9c3b7c4225a663b4a48732b7208a4d91c3b7d45305a6b55fa2a37fc4] - # Currently missing harness files (https://github.com/flutter/flutter/issues/86749): - # camera/camera - # google_sign_in/google_sign_in - # in_app_purchase/in_app_purchase - # in_app_purchase_android - # quick_actions - # shared_preferences/shared_preferences - # url_launcher/url_launcher - # video_player/video_player - # webview_flutter - # Deprecated; no plan to backfill the missing files: - # android_intent,connectivity/connectivity,device_info/device_info,sensors,share,wifi_info_flutter/wifi_info_flutter - # No integration tests to run: - # image_picker/image_picker - Native UI is the critical functionality - # espresso - No Dart code, so no integration tests - PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "camera/camera,google_sign_in/google_sign_in,in_app_purchase/in_app_purchase,in_app_purchase_android,quick_actions,shared_preferences/shared_preferences,url_launcher/url_launcher,video_player/video_player,webview_flutter,android_intent,connectivity/connectivity,device_info/device_info,sensors,share,wifi_info_flutter/wifi_info_flutter,image_picker/image_picker,espresso" build_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. @@ -177,16 +161,13 @@ task: - export CIRRUS_COMMIT_MESSAGE="" - if [[ -n "$GCLOUD_FIREBASE_TESTLAB_KEY" ]]; then - echo $GCLOUD_FIREBASE_TESTLAB_KEY > ${HOME}/gcloud-service-key.json - - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS + - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 --exclude=script/configs/exclude_integration_android.yaml - else - echo "This user does not have permission to run Firebase Test Lab tests." - fi ### Web tasks ### - name: build-web+drive-examples env: - # Currently missing; see https://github.com/flutter/flutter/issues/81982 - # and https://github.com/flutter/flutter/issues/82211 - PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "file_selector,shared_preferences_web" matrix: CHANNEL: "master" CHANNEL: "stable" @@ -199,7 +180,7 @@ task: build_script: - ./script/tool_runner.sh build-examples --web drive_script: - - ./script/tool_runner.sh drive-examples --web --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS + - ./script/tool_runner.sh drive-examples --web --exclude=script/configs/exclude_integration_web.yaml # macOS tasks. task: @@ -221,10 +202,6 @@ task: - name: build-ipas+drive-examples env: PATH: $PATH:/usr/local/bin - # in_app_purchase_ios is currently missing tests; see https://github.com/flutter/flutter/issues/81695 - # ios_platform_images is currently missing tests; see https://github.com/flutter/flutter/issues/82208 - # sensor hangs on CI. - PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "in_app_purchase_ios,ios_platform_images,sensors" matrix: PLUGIN_SHARDING: "--shardIndex 0 --shardCount 4" PLUGIN_SHARDING: "--shardIndex 1 --shardCount 4" @@ -247,7 +224,7 @@ task: # `drive-examples` contains integration tests, which changes the UI of the application. # This UI change sometimes affects `xctest`. # So we run `drive-examples` after `native-test`; changing the order will result ci failure. - - ./script/tool_runner.sh drive-examples --ios --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS + - ./script/tool_runner.sh drive-examples --ios --exclude=script/configs/exclude_integration_ios.yaml ### macOS desktop tasks ### - name: build_all_plugins_macos env: @@ -259,9 +236,6 @@ task: - ./script/build_all_plugins_app.sh macos - name: build-macos+drive-examples env: - # conncectivity_macos is deprecated, so is not getting unit test backfill. - # package_info is deprecated, so is not getting unit test backfill. - PLUGINS_TO_EXCLUDE_MACOS_XCTESTS: "connectivity_macos,package_info" matrix: CHANNEL: "master" CHANNEL: "stable" @@ -272,6 +246,6 @@ task: xcode_analyze_script: - ./script/tool_runner.sh xcode-analyze --macos native_test_script: - - ./script/tool_runner.sh native-test --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS + - ./script/tool_runner.sh native-test --macos --exclude=script/configs/exclude_native_macos.yaml drive_script: - ./script/tool_runner.sh drive-examples --macos diff --git a/packages/e2e/analysis_options.yaml b/packages/e2e/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/e2e/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/script/configs/README.md b/script/configs/README.md new file mode 100644 index 000000000000..96423cf2779b --- /dev/null +++ b/script/configs/README.md @@ -0,0 +1,8 @@ +This folder contains configuration files that are passed to commands in place +of plugin lists. They are primarily used by CI to opt specific packages out of +tests, but can also useful when running multi-plugin tests locally. + +**Any entry added to a file in this directory should include a comment**. +Skipping tests or checks for plugins is usually not something we want to do, +so should the comment should either include an issue link to the issue tracking +removing it or—much more rarely—explaining why it is a permanent exclusion. diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml new file mode 100644 index 000000000000..f6dc8e288b55 --- /dev/null +++ b/script/configs/custom_analysis.yaml @@ -0,0 +1,41 @@ +# Plugins that deliberately use their own analysis_options.yaml. +# +# This only exists to allow incrementally switching to the newer, stricter +# analysis_options.yaml based on flutter/flutter, rather than the original +# rules based on pedantic (now at analysis_options_legacy.yaml). +# +# DO NOT add new entries to the list, unless it is to push the legacy rules +# from a top-level package into more specific packages in order to incrementally +# migrate a federated plugin. +# +# TODO(ecosystem): Remove everything from this list. See: +# https://github.com/flutter/flutter/issues/76229 +- camera +- file_selector +- flutter_plugin_android_lifecycle +- google_maps_flutter +- google_sign_in +- image_picker +- in_app_purchase +- integration_test +- ios_platform_images +- local_auth +- plugin_platform_interface +- quick_actions +- shared_preferences +- url_launcher +- video_player +- webview_flutter + +# These plugins are deprecated in favor of the Community Plus versions, and +# will be removed from the repo once the critical support window has passed, +# so are not worth updating. +- android_alarm_manager +- android_intent +- battery +- connectivity +- device_info +- package_info +- sensors +- share +- wifi_info_flutter diff --git a/script/configs/exclude_integration_android.yaml b/script/configs/exclude_integration_android.yaml new file mode 100644 index 000000000000..9fc31ec2166a --- /dev/null +++ b/script/configs/exclude_integration_android.yaml @@ -0,0 +1,22 @@ +# Currently missing harness files: https://github.com/flutter/flutter/issues/86749) +- camera/camera +- google_sign_in/google_sign_in +- in_app_purchase/in_app_purchase +- in_app_purchase_android +- quick_actions +- shared_preferences/shared_preferences +- url_launcher/url_launcher +- video_player/video_player +- webview_flutter + +# Deprecated; no plan to backfill the missing files +- android_intent +- connectivity/connectivity +- device_info/device_info +- sensors +- share +- wifi_info_flutter/wifi_info_flutter + +# No integration tests to run: +- image_picker/image_picker +- espresso diff --git a/script/configs/exclude_integration_ios.yaml b/script/configs/exclude_integration_ios.yaml new file mode 100644 index 000000000000..e1ae6adf49cf --- /dev/null +++ b/script/configs/exclude_integration_ios.yaml @@ -0,0 +1,6 @@ +# Currently missing: https://github.com/flutter/flutter/issues/81695 +- in_app_purchase_ios +# Currently missing: https://github.com/flutter/flutter/issues/82208 +- ios_platform_images +# Hangs on CI. Deprecated, so there is no plan to fix it. +- sensors diff --git a/script/configs/exclude_integration_web.yaml b/script/configs/exclude_integration_web.yaml new file mode 100644 index 000000000000..99e20831b3c2 --- /dev/null +++ b/script/configs/exclude_integration_web.yaml @@ -0,0 +1,4 @@ +# Currently missing: https://github.com/flutter/flutter/issues/81982 +- shared_preferences_web +# Currently missing: https://github.com/flutter/flutter/issues/82211 +- file_selector diff --git a/script/configs/exclude_native_macos.yaml b/script/configs/exclude_native_macos.yaml new file mode 100644 index 000000000000..8a817a9c0178 --- /dev/null +++ b/script/configs/exclude_native_macos.yaml @@ -0,0 +1,3 @@ +# Deprecated plugins that will not be getting unit test backfill. +- connectivity_macos +- package_info diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index dc30c05f79c8..7d1eac01b760 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,5 +1,9 @@ ## NEXT +- `--exclude` and `--custom-analysis` now accept paths to YAML files that + contain lists of packages to exclude, in addition to just package names, + so that exclude lists can be maintained separately from scripts and CI + configuration. - Added an `xctest` flag to select specific test targets, to allow running only unit tests or integration tests. - **Breaking change**: Split Xcode analysis out of `xctest` and into a new diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index e56b95d88eb0..4fd15f027f50 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:platform/platform.dart'; +import 'package:yaml/yaml.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -23,7 +24,10 @@ class AnalyzeCommand extends PackageLoopingCommand { }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addMultiOption(_customAnalysisFlag, help: - 'Directories (comma separated) that are allowed to have their own analysis options.', + 'Directories (comma separated) that are allowed to have their own ' + 'analysis options.\n\n' + 'Alternately, a list of one or more YAML files that contain a list ' + 'of allowed directories.', defaultsTo: []); argParser.addOption(_analysisSdk, valueHelp: 'dart-sdk', @@ -37,6 +41,8 @@ class AnalyzeCommand extends PackageLoopingCommand { late String _dartBinaryPath; + Set _allowedCustomAnalysisDirectories = const {}; + @override final String name = 'analyze'; @@ -56,7 +62,7 @@ class AnalyzeCommand extends PackageLoopingCommand { continue; } - final bool allowed = (getStringListArg(_customAnalysisFlag)).any( + final bool allowed = _allowedCustomAnalysisDirectories.any( (String directory) => directory.isNotEmpty && path.isWithin( @@ -107,6 +113,17 @@ class AnalyzeCommand extends PackageLoopingCommand { throw ToolExit(_exitPackagesGetFailed); } + _allowedCustomAnalysisDirectories = + getStringListArg(_customAnalysisFlag).expand((String item) { + if (item.endsWith('.yaml')) { + final File file = packagesDir.fileSystem.file(item); + return (loadYaml(file.readAsStringSync()) as YamlList) + .toList() + .cast(); + } + return [item]; + }).toSet(); + // Use the Dart SDK override if one was passed in. final String? dartSdk = argResults![_analysisSdk] as String?; _dartBinaryPath = diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index ecdcb0565d35..7781eee0d961 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -9,6 +9,7 @@ import 'package:file/file.dart'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; +import 'package:yaml/yaml.dart'; import 'core.dart'; import 'git_version_finder.dart'; @@ -48,7 +49,9 @@ abstract class PluginCommand extends Command { argParser.addMultiOption( _excludeArg, abbr: 'e', - help: 'Exclude packages from this command.', + help: 'A list of packages to exclude from from this command.\n\n' + 'Alternately, a list of one or more YAML files that contain a list ' + 'of packages to exclude.', defaultsTo: [], ); argParser.addFlag(_runOnChangedPackagesArg, @@ -214,8 +217,18 @@ abstract class PluginCommand extends Command { /// of packages in the flutter/packages repository. Stream _getAllPlugins() async* { Set plugins = Set.from(getStringListArg(_packagesArg)); + final Set excludedPlugins = - Set.from(getStringListArg(_excludeArg)); + getStringListArg(_excludeArg).expand((String item) { + if (item.endsWith('.yaml')) { + final File file = packagesDir.fileSystem.file(item); + return (loadYaml(file.readAsStringSync()) as YamlList) + .toList() + .cast(); + } + return [item]; + }).toSet(); + final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); if (plugins.isEmpty && runOnChangedPackages && diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 69a2c4f95523..9dc8b6a3fca5 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -176,6 +176,25 @@ void main() { ])); }); + test('takes an allow config file', () async { + final Directory pluginDir = createFakePlugin('foo', packagesDir, + extraFiles: ['analysis_options.yaml']); + final File allowFile = packagesDir.childFile('custom.yaml'); + allowFile.writeAsStringSync('- foo'); + + await runCapturingPrint( + runner, ['analyze', '--custom-analysis', allowFile.path]); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'flutter', const ['packages', 'get'], pluginDir.path), + ProcessCall('dart', const ['analyze', '--fatal-infos'], + pluginDir.path), + ])); + }); + // See: https://github.com/flutter/flutter/issues/78994 test('takes an empty allow list', () async { createFakePlugin('foo', packagesDir, diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index fdab9612be3f..7f67acfb2df3 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -172,6 +172,19 @@ void main() { expect(plugins, unorderedEquals([plugin2.path])); }); + test('exclude accepts config files', () async { + createFakePlugin('plugin1', packagesDir); + final File configFile = packagesDir.childFile('exclude.yaml'); + configFile.writeAsStringSync('- plugin1'); + + await runCapturingPrint(runner, [ + 'sample', + '--packages=plugin1', + '--exclude=${configFile.path}' + ]); + expect(plugins, unorderedEquals([])); + }); + group('test run-on-changed-packages', () { test('all plugins should be tested if there are no changes.', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); diff --git a/script/tool_runner.sh b/script/tool_runner.sh index d16e940d5a4d..11a54ce435a4 100755 --- a/script/tool_runner.sh +++ b/script/tool_runner.sh @@ -10,58 +10,7 @@ readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" source "$SCRIPT_DIR/common.sh" -# Plugins that are excluded from this task. -ALL_EXCLUDED=("") - -# Plugins that deliberately use their own analysis_options.yaml. -# -# This list should only be deleted from, never added to. This only exists -# because we adopted stricter analysis rules recently and needed to exclude -# already failing packages to start linting the repo as a whole. -# -# Finding all: `find packages -name analysis_options.yaml | sort | cut -d/ -f2` -# -# TODO(ecosystem): Remove everything from this list. https://github.com/flutter/flutter/issues/76229 -CUSTOM_ANALYSIS_PLUGINS=( - android_alarm_manager - android_intent - battery - camera - connectivity - cross_file - device_info - e2e - espresso - file_selector - flutter_plugin_android_lifecycle - google_maps_flutter - google_sign_in - image_picker - in_app_purchase - integration_test - ios_platform_images - local_auth - package_info - plugin_platform_interface - quick_actions - sensors - share - shared_preferences - url_launcher - video_player - webview_flutter - wifi_info_flutter -) - -# Comma-separated string of the list above -readonly CUSTOM_FLAG=$(IFS=, ; echo "${CUSTOM_ANALYSIS_PLUGINS[*]}") -# Set some default actions if run without arguments. ACTIONS=("$@") -if [[ "${#ACTIONS[@]}" == 0 ]]; then - ACTIONS=("analyze" "--custom-analysis" "$CUSTOM_FLAG" "test" "java-test") -elif [[ "${ACTIONS[0]}" == "analyze" ]]; then - ACTIONS=("${ACTIONS[@]}" "--custom-analysis" "$CUSTOM_FLAG") -fi BRANCH_NAME="${BRANCH_NAME:-"$(git rev-parse --abbrev-ref HEAD)"}" @@ -71,8 +20,8 @@ PLUGIN_SHARDING=($PLUGIN_SHARDING) if [[ "${BRANCH_NAME}" == "master" ]]; then echo "Running for all packages" - (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" --exclude="$ALL_EXCLUDED" ${PLUGIN_SHARDING[@]}) + (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" ${PLUGIN_SHARDING[@]}) else echo running "${ACTIONS[@]}" - (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" --run-on-changed-packages --exclude="$ALL_EXCLUDED" ${PLUGIN_SHARDING[@]}) + (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" --run-on-changed-packages ${PLUGIN_SHARDING[@]}) fi From dd66f34a767d0df715948c751bf3bc8db3574af5 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 23 Jul 2021 01:22:48 +0200 Subject: [PATCH 149/364] [camera_web] Add `createCamera` implementation (#4182) --- .../camera_settings_test.dart | 95 ++++++++++++ .../integration_test/camera_web_test.dart | 144 ++++++++++++++++-- .../camera_web/lib/src/camera_settings.dart | 35 +++++ .../camera/camera_web/lib/src/camera_web.dart | 53 ++++++- .../lib/src/types/camera_error_codes.dart | 3 + 5 files changed, 318 insertions(+), 12 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart index c1c00fe7a337..ddfb86e4ec0a 100644 --- a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:html'; +import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera_settings.dart'; @@ -204,6 +205,100 @@ void main() { ); }); }); + + group('mapFacingModeToCameraType', () { + testWidgets( + 'returns user ' + 'when the facing mode is user', (tester) async { + expect( + settings.mapFacingModeToCameraType('user'), + equals(CameraType.user), + ); + }); + + testWidgets( + 'returns environment ' + 'when the facing mode is environment', (tester) async { + expect( + settings.mapFacingModeToCameraType('environment'), + equals(CameraType.environment), + ); + }); + + testWidgets( + 'returns user ' + 'when the facing mode is left', (tester) async { + expect( + settings.mapFacingModeToCameraType('left'), + equals(CameraType.user), + ); + }); + + testWidgets( + 'returns user ' + 'when the facing mode is right', (tester) async { + expect( + settings.mapFacingModeToCameraType('right'), + equals(CameraType.user), + ); + }); + }); + + group('mapResolutionPresetToSize', () { + testWidgets( + 'returns 3840x2160 ' + 'when the resolution preset is max', (tester) async { + expect( + settings.mapResolutionPresetToSize(ResolutionPreset.max), + equals(Size(3840, 2160)), + ); + }); + + testWidgets( + 'returns 3840x2160 ' + 'when the resolution preset is ultraHigh', (tester) async { + expect( + settings.mapResolutionPresetToSize(ResolutionPreset.ultraHigh), + equals(Size(3840, 2160)), + ); + }); + + testWidgets( + 'returns 1920x1080 ' + 'when the resolution preset is veryHigh', (tester) async { + expect( + settings.mapResolutionPresetToSize(ResolutionPreset.veryHigh), + equals(Size(1920, 1080)), + ); + }); + + testWidgets( + 'returns 1280x720 ' + 'when the resolution preset is high', (tester) async { + expect( + settings.mapResolutionPresetToSize(ResolutionPreset.high), + equals(Size(1280, 720)), + ); + }); + + testWidgets( + 'returns 720x480 ' + 'when the resolution preset is medium', (tester) async { + expect( + settings.mapResolutionPresetToSize(ResolutionPreset.medium), + equals(Size(720, 480)), + ); + }); + + testWidgets( + 'returns 320x240 ' + 'when the resolution preset is low', (tester) async { + expect( + settings.mapResolutionPresetToSize(ResolutionPreset.low), + equals(Size(320, 240)), + ); + }); + }); }); } diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 25368daf02f7..eef17ecfdff9 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -3,9 +3,11 @@ // found in the LICENSE file. import 'dart:html'; +import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/camera_web.dart'; +import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/services.dart'; @@ -296,18 +298,140 @@ void main() { }); }); - testWidgets('createCamera throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.createCamera( - CameraDescription( + group('createCamera', () { + testWidgets( + 'throws CameraException ' + 'with missingMetadata error ' + 'if there is no metadata ' + 'for the given camera description', (tester) async { + expect( + () => CameraPlatform.instance.createCamera( + CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.back, + sensorOrientation: 0, + ), + ResolutionPreset.ultraHigh, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.missingMetadata, + ), + ), + ); + }); + + group('creates a camera', () { + const ultraHighResolutionSize = Size(3840, 2160); + const maxResolutionSize = Size(3840, 2160); + + late CameraDescription cameraDescription; + late CameraMetadata cameraMetadata; + + setUp(() { + cameraDescription = CameraDescription( name: 'name', - lensDirection: CameraLensDirection.external, + lensDirection: CameraLensDirection.front, sensorOrientation: 0, - ), - ResolutionPreset.medium, - ), - throwsUnimplementedError, - ); + ); + + cameraMetadata = CameraMetadata( + deviceId: 'deviceId', + facingMode: 'user', + ); + + // Add metadata for the camera description. + (CameraPlatform.instance as CameraPlugin) + .camerasMetadata[cameraDescription] = cameraMetadata; + + when( + () => cameraSettings.mapFacingModeToCameraType('user'), + ).thenReturn(CameraType.user); + }); + + testWidgets('with appropriate options', (tester) async { + when( + () => cameraSettings + .mapResolutionPresetToSize(ResolutionPreset.ultraHigh), + ).thenReturn(ultraHighResolutionSize); + + final cameraId = await CameraPlatform.instance.createCamera( + cameraDescription, + ResolutionPreset.ultraHigh, + enableAudio: true, + ); + + expect( + (CameraPlatform.instance as CameraPlugin).cameras[cameraId], + isA() + .having( + (camera) => camera.textureId, + 'textureId', + cameraId, + ) + .having( + (camera) => camera.window, + 'window', + window, + ) + .having( + (camera) => camera.options, + 'options', + CameraOptions( + audio: AudioConstraints(enabled: true), + video: VideoConstraints( + facingMode: FacingModeConstraint(CameraType.user), + width: VideoSizeConstraint( + ideal: ultraHighResolutionSize.width.toInt(), + ), + height: VideoSizeConstraint( + ideal: ultraHighResolutionSize.height.toInt(), + ), + deviceId: cameraMetadata.deviceId, + ), + ), + ), + ); + }); + + testWidgets( + 'with a max resolution preset ' + 'and enabled audio set to false ' + 'when no options are specified', (tester) async { + when( + () => + cameraSettings.mapResolutionPresetToSize(ResolutionPreset.max), + ).thenReturn(maxResolutionSize); + + final cameraId = await CameraPlatform.instance.createCamera( + cameraDescription, + null, + ); + + expect( + (CameraPlatform.instance as CameraPlugin).cameras[cameraId], + isA().having( + (camera) => camera.options, + 'options', + CameraOptions( + audio: AudioConstraints(enabled: false), + video: VideoConstraints( + facingMode: FacingModeConstraint(CameraType.user), + width: VideoSizeConstraint( + ideal: maxResolutionSize.width.toInt(), + ), + height: VideoSizeConstraint( + ideal: maxResolutionSize.height.toInt(), + ), + deviceId: cameraMetadata.deviceId, + ), + ), + ), + ); + }); + }); }); testWidgets('initializeCamera throws UnimplementedError', (tester) async { diff --git a/packages/camera/camera_web/lib/src/camera_settings.dart b/packages/camera/camera_web/lib/src/camera_settings.dart index 2a1a31ff1cf5..7b87840a90f8 100644 --- a/packages/camera/camera_web/lib/src/camera_settings.dart +++ b/packages/camera/camera_web/lib/src/camera_settings.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:html' as html; +import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/types/types.dart'; @@ -105,4 +106,38 @@ class CameraSettings { return CameraLensDirection.external; } } + + /// Maps the given [facingMode] to [CameraType]. + /// + /// See [CameraMetadata.facingMode] for more details. + CameraType mapFacingModeToCameraType(String facingMode) { + switch (facingMode) { + case 'user': + return CameraType.user; + case 'environment': + return CameraType.environment; + case 'left': + case 'right': + default: + return CameraType.user; + } + } + + /// Maps the given [resolutionPreset] to [Size]. + Size mapResolutionPresetToSize(ResolutionPreset resolutionPreset) { + switch (resolutionPreset) { + case ResolutionPreset.max: + case ResolutionPreset.ultraHigh: + return Size(3840, 2160); + case ResolutionPreset.veryHigh: + return Size(1920, 1080); + case ResolutionPreset.high: + return Size(1280, 720); + case ResolutionPreset.medium: + return Size(720, 480); + case ResolutionPreset.low: + default: + return Size(320, 240); + } + } } diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index ae9937dd94d3..80ab13d37d13 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -7,6 +7,7 @@ import 'dart:html' as html; import 'dart:math'; import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/material.dart'; @@ -31,6 +32,11 @@ class CameraPlugin extends CameraPlatform { final CameraSettings _cameraSettings; + /// The cameras managed by the [CameraPlugin]. + @visibleForTesting + final cameras = {}; + var _textureCounter = 1; + /// Metadata associated with each camera description. /// Populated in [availableCameras]. @visibleForTesting @@ -130,8 +136,51 @@ class CameraPlugin extends CameraPlatform { CameraDescription cameraDescription, ResolutionPreset? resolutionPreset, { bool enableAudio = false, - }) { - throw UnimplementedError('createCamera() is not implemented.'); + }) async { + if (!camerasMetadata.containsKey(cameraDescription)) { + throw CameraException( + CameraErrorCodes.missingMetadata, + 'Missing camera metadata. Make sure to call `availableCameras` before creating a camera.', + ); + } + + final textureId = _textureCounter++; + + final cameraMetadata = camerasMetadata[cameraDescription]!; + + final cameraType = cameraMetadata.facingMode != null + ? _cameraSettings.mapFacingModeToCameraType(cameraMetadata.facingMode!) + : null; + + // Use the highest resolution possible + // if the resolution preset is not specified. + final videoSize = _cameraSettings + .mapResolutionPresetToSize(resolutionPreset ?? ResolutionPreset.max); + + // Create a camera with the given audio and video constraints. + // Sensor orientation is currently not supported. + final camera = Camera( + textureId: textureId, + window: window, + options: CameraOptions( + audio: AudioConstraints(enabled: enableAudio), + video: VideoConstraints( + facingMode: + cameraType != null ? FacingModeConstraint(cameraType) : null, + width: VideoSizeConstraint( + ideal: videoSize.width.toInt(), + ), + height: VideoSizeConstraint( + ideal: videoSize.height.toInt(), + ), + deviceId: cameraMetadata.deviceId, + ), + ), + ); + + cameras[textureId] = camera; + + return textureId; } @override diff --git a/packages/camera/camera_web/lib/src/types/camera_error_codes.dart b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart index f8dc5dfc4e32..afb02ae3aaa9 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_codes.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart @@ -24,6 +24,9 @@ abstract class CameraErrorCodes { /// to access the media input from an insecure context. static const type = 'cameraType'; + /// The camera metadata is missing. + static const missingMetadata = 'missingMetadata'; + /// An unknown camera error. static const unknown = 'cameraUnknown'; } From e843c035cf63e73a1d543c5b14079729cb473f8d Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Fri, 23 Jul 2021 11:04:24 -0700 Subject: [PATCH 150/364] Remove references to the V1 Android embedding (#4160) --- packages/android_alarm_manager/CHANGELOG.md | 4 ++ packages/android_alarm_manager/README.md | 49 ------------------- .../androidalarmmanager/AlarmService.java | 19 ------- .../AndroidAlarmManagerPlugin.java | 2 - .../FlutterBackgroundExecutor.java | 40 ++++----------- .../PluginRegistrantException.java | 13 ----- .../android/app/src/main/AndroidManifest.xml | 10 ---- .../Application.java | 26 ---------- .../EmbeddingV1Activity.java | 20 -------- packages/android_intent/CHANGELOG.md | 4 ++ .../EmbeddingV1ActivityTest.java | 17 ------- .../android/app/src/main/AndroidManifest.xml | 17 +------ .../EmbeddingV1Activity.java | 18 ------- packages/camera/camera/CHANGELOG.md | 6 ++- .../EmbeddingV1ActivityTest.java | 18 ------- .../android/app/src/main/AndroidManifest.xml | 15 +----- .../cameraexample/EmbeddingV1Activity.java | 26 ---------- packages/camera/camera/pubspec.yaml | 2 +- .../connectivity/connectivity/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 10 +--- .../EmbeddingV1Activity.java | 18 ------- .../EmbeddingV1ActivityTest.java | 18 ------- packages/device_info/device_info/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 13 +---- .../EmbeddingV1Activity.java | 17 ------- .../EmbeddingV1ActivityTest.java | 18 ------- packages/espresso/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 6 --- packages/espresso/pubspec.yaml | 2 +- .../CHANGELOG.md | 11 +++-- .../EmbeddingV1ActivityTest.java | 18 ------- .../android/app/src/main/AndroidManifest.xml | 14 ------ .../EmbeddingV1Activity.java | 22 --------- .../pubspec.yaml | 2 +- .../google_maps_flutter/CHANGELOG.md | 3 +- .../googlemaps/EmbeddingV1ActivityTest.java | 19 ------- .../android/app/src/main/AndroidManifest.xml | 12 +---- .../EmbeddingV1Activity.java | 20 -------- .../google_maps_flutter/pubspec.yaml | 2 +- .../google_sign_in/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 6 --- .../EmbeddingV1Activity.java | 19 ------- .../EmbeddingV1ActivityTest.java | 18 ------- .../google_sign_in/pubspec.yaml | 2 +- .../android/app/src/main/AndroidManifest.xml | 7 --- .../EmbeddingV1Activity.java | 21 -------- .../EmbeddingV1ActivityTest.java | 18 ------- .../in_app_purchase/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 27 +--------- .../EmbeddingV1Activity.java | 24 --------- .../EmbeddingV1ActivityTest.java | 18 ------- .../in_app_purchase/pubspec.yaml | 2 +- .../android/app/src/main/AndroidManifest.xml | 23 --------- .../EmbeddingV1Activity.java | 24 --------- .../EmbeddingV1ActivityTest.java | 18 ------- packages/local_auth/CHANGELOG.md | 4 ++ .../localauth/EmbeddingV1ActivityTest.java | 19 ------- .../android/app/src/main/AndroidManifest.xml | 8 +-- .../localauthexample/EmbeddingV1Activity.java | 25 ---------- packages/local_auth/pubspec.yaml | 2 +- packages/package_info/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 2 +- .../path_provider/path_provider/CHANGELOG.md | 3 +- .../java/EmbeddingV1ActivityTest.java | 19 ------- .../android/app/src/main/AndroidManifest.xml | 8 +-- .../EmbeddingV1Activity.java | 21 -------- .../path_provider/path_provider/pubspec.yaml | 2 +- .../quick_actions/quick_actions/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 32 +++++------- .../EmbeddingV1Activity.java | 18 ------- .../EmbeddingV1ActivityTest.java | 18 ------- .../quick_actions/quick_actions/pubspec.yaml | 2 +- packages/sensors/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 9 +--- .../sensorsexample/EmbeddingV1Activity.java | 20 -------- .../EmbeddingV1ActivityTest.java | 18 ------- packages/share/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 9 +--- .../shareexample/EmbeddingV1Activity.java | 21 -------- .../shareexample/EmbeddingV1ActivityTest.java | 18 ------- .../url_launcher/url_launcher/CHANGELOG.md | 4 ++ .../EmbeddingV1ActivityTest.java | 18 ------- .../android/app/src/main/AndroidManifest.xml | 16 +----- .../EmbeddingV1Activity.java | 22 --------- .../url_launcher/url_launcher/pubspec.yaml | 2 +- .../video_player/video_player/CHANGELOG.md | 4 ++ .../android/app/src/main/AndroidManifest.xml | 13 ----- .../EmbeddingV1Activity.java | 21 -------- .../video_player/video_player/pubspec.yaml | 2 +- .../webview_flutter/CHANGELOG.md | 4 ++ .../EmbeddingV1ActivityTest.java | 18 ------- .../android/app/src/main/AndroidManifest.xml | 9 +--- .../webview_flutter/pubspec.yaml | 2 +- 93 files changed, 123 insertions(+), 1084 deletions(-) delete mode 100644 packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/PluginRegistrantException.java delete mode 100644 packages/android_alarm_manager/example/android/app/src/main/java/io/flutter/plugins/androidalarmmanagerexample/Application.java delete mode 100644 packages/android_alarm_manager/example/android/app/src/main/java/io/flutter/plugins/androidalarmmanagerexample/EmbeddingV1Activity.java delete mode 100644 packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/androidintentexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/android_intent/example/android/app/src/main/java/io/flutter/plugins/androidintentexample/EmbeddingV1Activity.java delete mode 100644 packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/camera/camera/example/android/app/src/main/java/io/flutter/plugins/cameraexample/EmbeddingV1Activity.java delete mode 100644 packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/connectivityexample/EmbeddingV1Activity.java delete mode 100644 packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/connectivityexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1Activity.java delete mode 100644 packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/EmbeddingV1ActivityTest.java delete mode 100644 packages/flutter_plugin_android_lifecycle/example/android/app/src/main/java/io/flutter/plugins/flutter_plugin_android_lifecycle_example/EmbeddingV1Activity.java delete mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/EmbeddingV1ActivityTest.java delete mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/java/io/flutter/plugins/googlemapsexample/EmbeddingV1Activity.java delete mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1Activity.java delete mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1Activity.java delete mode 100644 packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1Activity.java delete mode 100644 packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1Activity.java delete mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/EmbeddingV1ActivityTest.java delete mode 100644 packages/local_auth/example/android/app/src/main/java/io/flutter/plugins/localauthexample/EmbeddingV1Activity.java delete mode 100644 packages/path_provider/path_provider/example/android/app/src/androidTest/java/EmbeddingV1ActivityTest.java delete mode 100644 packages/path_provider/path_provider/example/android/app/src/main/java/io/flutter/plugins/pathproviderexample/EmbeddingV1Activity.java delete mode 100644 packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1Activity.java delete mode 100644 packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/sensors/example/android/app/src/main/java/io/flutter/plugins/sensorsexample/EmbeddingV1Activity.java delete mode 100644 packages/sensors/example/android/app/src/main/java/io/flutter/plugins/sensorsexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/share/example/android/app/src/main/java/io/flutter/plugins/shareexample/EmbeddingV1Activity.java delete mode 100644 packages/share/example/android/app/src/main/java/io/flutter/plugins/shareexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/EmbeddingV1ActivityTest.java delete mode 100644 packages/url_launcher/url_launcher/example/android/app/src/main/java/io/flutter/plugins/urllauncherexample/EmbeddingV1Activity.java delete mode 100644 packages/video_player/video_player/example/android/app/src/main/java/io/flutter/plugins/videoplayerexample/EmbeddingV1Activity.java delete mode 100644 packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java diff --git a/packages/android_alarm_manager/CHANGELOG.md b/packages/android_alarm_manager/CHANGELOG.md index 7c40428c22ba..71f47cede66e 100644 --- a/packages/android_alarm_manager/CHANGELOG.md +++ b/packages/android_alarm_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove support for the V1 Android embedding. + ## 2.0.2 * Update README to point to Plus Plugins version. diff --git a/packages/android_alarm_manager/README.md b/packages/android_alarm_manager/README.md index 500c5d5232f9..beefa985ef10 100644 --- a/packages/android_alarm_manager/README.md +++ b/packages/android_alarm_manager/README.md @@ -74,55 +74,6 @@ will not run in the same isolate as the main application. Unlike threads, isolat memory and communication between isolates must be done via message passing (see more documentation on isolates [here](https://api.dart.dev/stable/2.0.0/dart-isolate/dart-isolate-library.html)). - -## Using other plugins in alarm callbacks - -If alarm callbacks will need access to other Flutter plugins, including the -alarm manager plugin itself, it may be necessary to inform the background service how -to initialize plugins depending on which Flutter Android embedding the application is -using. - -### Flutter Android Embedding V1 - -For the Flutter Android Embedding V1, the background service must be provided a -callback to register plugins with the background isolate. This is done by giving -the `AlarmService` a callback to call the application's `onCreate` method. See the example's -[Application overrides](https://github.com/flutter/plugins/blob/master/packages/android_alarm_manager/example/android/app/src/main/java/io/flutter/plugins/androidalarmmanagerexample/Application.java). - -In particular, its `Application` class is as follows: - -```java -public class Application extends FlutterApplication implements PluginRegistrantCallback { - @Override - public void onCreate() { - super.onCreate(); - AlarmService.setPluginRegistrant(this); - } - - @Override - public void registerWith(PluginRegistry registry) { - GeneratedPluginRegistrant.registerWith(registry); - } -} -``` - -Which must be reflected in the application's `AndroidManifest.xml`. E.g.: - -```xml - *

  • The given {@code callbackHandle} must correspond to a registered Dart callback. If the * handle does not resolve to a Dart callback then this method does nothing. - *
  • A static {@link #pluginRegistrantCallback} must exist, otherwise a {@link - * PluginRegistrantException} will be thrown. * */ public static void startBackgroundIsolate(Context context, long callbackHandle) { @@ -89,23 +87,6 @@ public static void setCallbackDispatcher(Context context, long callbackHandle) { FlutterBackgroundExecutor.setCallbackDispatcher(context, callbackHandle); } - /** - * Sets the {@link io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback} used to - * register the plugins used by an application with the newly spawned background isolate. - * - *

    This should be invoked in {@link Application.onCreate} with {@link - * GeneratedPluginRegistrant} in applications using the V1 embedding API in order to use other - * plugins in the background isolate. For applications using the V2 embedding API, it is not - * necessary to set a {@link io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback} as - * plugins are registered automatically. - */ - @SuppressWarnings("deprecation") - public static void setPluginRegistrant( - io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback callback) { - // Indirectly set in FlutterBackgroundExecutor for backwards compatibility. - FlutterBackgroundExecutor.setPluginRegistrant(callback); - } - private static void scheduleAlarm( Context context, int requestCode, diff --git a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AndroidAlarmManagerPlugin.java b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AndroidAlarmManagerPlugin.java index fd3a9c5e87dd..45f047b5ae68 100644 --- a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AndroidAlarmManagerPlugin.java +++ b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AndroidAlarmManagerPlugin.java @@ -147,8 +147,6 @@ public void onMethodCall(MethodCall call, Result result) { } } catch (JSONException e) { result.error("error", "JSON error: " + e.getMessage(), null); - } catch (PluginRegistrantException e) { - result.error("error", "AlarmManager error: " + e.getMessage(), null); } } diff --git a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/FlutterBackgroundExecutor.java b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/FlutterBackgroundExecutor.java index d9c40bfe7181..0aa08ed216e0 100644 --- a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/FlutterBackgroundExecutor.java +++ b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/FlutterBackgroundExecutor.java @@ -45,20 +45,6 @@ public class FlutterBackgroundExecutor implements MethodCallHandler { private AtomicBoolean isCallbackDispatcherReady = new AtomicBoolean(false); - /** - * Sets the {@code io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback} used to - * register plugins with the newly spawned isolate. - * - *

    Note: this is only necessary for applications using the V1 engine embedding API as plugins - * are automatically registered via reflection in the V2 engine embedding API. If not set, alarm - * callbacks will not be able to utilize functionality from other plugins. - */ - @SuppressWarnings("deprecation") - public static void setPluginRegistrant( - io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback callback) { - pluginRegistrantCallback = callback; - } - /** * Sets the Dart callback handle for the Dart method that is responsible for initializing the * background Dart isolate, preparing it to receive Dart callback tasks requests. @@ -81,19 +67,15 @@ private void onInitialized() { @Override public void onMethodCall(MethodCall call, Result result) { String method = call.method; - try { - if (method.equals("AlarmService.initialized")) { - // This message is sent by the background method channel as soon as the background isolate - // is running. From this point forward, the Android side of this plugin can send - // callback handles through the background method channel, and the Dart side will execute - // the Dart methods corresponding to those callback handles. - onInitialized(); - result.success(true); - } else { - result.notImplemented(); - } - } catch (PluginRegistrantException e) { - result.error("error", "AlarmManager error: " + e.getMessage(), null); + if (method.equals("AlarmService.initialized")) { + // This message is sent by the background method channel as soon as the background isolate + // is running. From this point forward, the Android side of this plugin can send + // callback handles through the background method channel, and the Dart side will execute + // the Dart methods corresponding to those callback handles. + onInitialized(); + result.success(true); + } else { + result.notImplemented(); } } @@ -115,8 +97,6 @@ public void onMethodCall(MethodCall call, Result result) { *

      *
    • The given callback must correspond to a registered Dart callback. If the handle does not * resolve to a Dart callback then this method does nothing. - *
    • A static {@link #pluginRegistrantCallback} must exist, otherwise a {@link - * PluginRegistrantException} will be thrown. *
    */ public void startBackgroundIsolate(Context context) { @@ -143,8 +123,6 @@ public void startBackgroundIsolate(Context context) { *
      *
    • The given {@code callbackHandle} must correspond to a registered Dart callback. If the * handle does not resolve to a Dart callback then this method does nothing. - *
    • A static {@link #pluginRegistrantCallback} must exist, otherwise a {@link - * PluginRegistrantException} will be thrown. *
    */ public void startBackgroundIsolate(Context context, long callbackHandle) { diff --git a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/PluginRegistrantException.java b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/PluginRegistrantException.java deleted file mode 100644 index afbc1c71bd3f..000000000000 --- a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/PluginRegistrantException.java +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.androidalarmmanager; - -class PluginRegistrantException extends RuntimeException { - public PluginRegistrantException() { - super( - "PluginRegistrantCallback is not set. Did you forget to call " - + "AlarmService.setPluginRegistrant? See the README for instructions."); - } -} diff --git a/packages/android_alarm_manager/example/android/app/src/main/AndroidManifest.xml b/packages/android_alarm_manager/example/android/app/src/main/AndroidManifest.xml index 2a9dc331ebf1..2fef38483800 100644 --- a/packages/android_alarm_manager/example/android/app/src/main/AndroidManifest.xml +++ b/packages/android_alarm_manager/example/android/app/src/main/AndroidManifest.xml @@ -6,18 +6,8 @@ - - rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/android_intent/example/android/app/src/main/AndroidManifest.xml b/packages/android_intent/example/android/app/src/main/AndroidManifest.xml index 761c35fd64d8..e0aa7f84d7b9 100644 --- a/packages/android_intent/example/android/app/src/main/AndroidManifest.xml +++ b/packages/android_intent/example/android/app/src/main/AndroidManifest.xml @@ -1,23 +1,8 @@ - - - - - + android:label="android_intent_example"> rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/camera/camera/example/android/app/src/main/AndroidManifest.xml b/packages/camera/camera/example/android/app/src/main/AndroidManifest.xml index f216a7251bcf..cef23162ddb6 100644 --- a/packages/camera/camera/example/android/app/src/main/AndroidManifest.xml +++ b/packages/camera/camera/example/android/app/src/main/AndroidManifest.xml @@ -3,20 +3,7 @@ - - - + android:label="camera_example"> =2.12.0 <3.0.0" diff --git a/packages/connectivity/connectivity/CHANGELOG.md b/packages/connectivity/connectivity/CHANGELOG.md index 89db7aeba9bb..58047482fcb7 100644 --- a/packages/connectivity/connectivity/CHANGELOG.md +++ b/packages/connectivity/connectivity/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove references to the Android V1 embedding. + ## 3.0.6 * Update README to point to Plus Plugins version. diff --git a/packages/connectivity/connectivity/example/android/app/src/main/AndroidManifest.xml b/packages/connectivity/connectivity/example/android/app/src/main/AndroidManifest.xml index 902642e0ca49..abce0da89989 100644 --- a/packages/connectivity/connectivity/example/android/app/src/main/AndroidManifest.xml +++ b/packages/connectivity/connectivity/example/android/app/src/main/AndroidManifest.xml @@ -3,15 +3,7 @@ - - - + rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/device_info/device_info/CHANGELOG.md b/packages/device_info/device_info/CHANGELOG.md index a92cb8ce94b1..669423cc4efb 100644 --- a/packages/device_info/device_info/CHANGELOG.md +++ b/packages/device_info/device_info/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove references to the Android V1 embedding. + ## 2.0.2 * Update README to point to Plus Plugins version. diff --git a/packages/device_info/device_info/example/android/app/src/main/AndroidManifest.xml b/packages/device_info/device_info/example/android/app/src/main/AndroidManifest.xml index f9f91fa39dae..4268475986a3 100644 --- a/packages/device_info/device_info/example/android/app/src/main/AndroidManifest.xml +++ b/packages/device_info/device_info/example/android/app/src/main/AndroidManifest.xml @@ -3,16 +3,7 @@ - - - - + - + diff --git a/packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1Activity.java b/packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1Activity.java deleted file mode 100644 index 86966cd137bb..000000000000 --- a/packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1Activity.java +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.deviceinfoexample; - -import android.os.Bundle; -import io.flutter.plugins.deviceinfo.DeviceInfoPlugin; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - DeviceInfoPlugin.registerWith(registrarFor("io.flutter.plugins.deviceinfo.DeviceInfoPlugin")); - } -} diff --git a/packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1ActivityTest.java b/packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index a9babfe803ae..000000000000 --- a/packages/device_info/device_info/example/android/app/src/main/java/io/flutter/plugins/deviceinfoexample/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.deviceinfoexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/espresso/CHANGELOG.md b/packages/espresso/CHANGELOG.md index 4699db18c579..10e5ae59f71a 100644 --- a/packages/espresso/CHANGELOG.md +++ b/packages/espresso/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.0+3 + +* Remove references to the Android v1 embedding. + ## 0.1.0+2 * Migrate maven repo from jcenter to mavenCentral diff --git a/packages/espresso/example/android/app/src/main/AndroidManifest.xml b/packages/espresso/example/android/app/src/main/AndroidManifest.xml index b82df920d3bc..366373e997dc 100644 --- a/packages/espresso/example/android/app/src/main/AndroidManifest.xml +++ b/packages/espresso/example/android/app/src/main/AndroidManifest.xml @@ -1,12 +1,6 @@ - =2.12.0 <3.0.0" diff --git a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md index f24a22332eaa..6a05ed01e2de 100644 --- a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md +++ b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md @@ -1,9 +1,14 @@ +## 2.0.3 + +* Remove references to the Android V1 embedding. + ## 2.0.2 -* Migrate maven repo from jcenter to mavenCentral + +* Migrate maven repo from jcenter to mavenCentral. ## 2.0.1 -* Make sure androidx.lifecycle.DefaultLifecycleObservable doesn't get shrunk - away. + +* Make sure androidx.lifecycle.DefaultLifecycleObservable doesn't get shrunk away. ## 2.0.0 diff --git a/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/EmbeddingV1ActivityTest.java b/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/EmbeddingV1ActivityTest.java deleted file mode 100644 index 84173f4a9c0f..000000000000 --- a/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.flutter_plugin_android_lifecycle_example; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/AndroidManifest.xml b/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/AndroidManifest.xml index 74f1397fc707..d00868f25cbf 100644 --- a/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/AndroidManifest.xml +++ b/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/AndroidManifest.xml @@ -1,12 +1,6 @@ - - - diff --git a/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/java/io/flutter/plugins/flutter_plugin_android_lifecycle_example/EmbeddingV1Activity.java b/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/java/io/flutter/plugins/flutter_plugin_android_lifecycle_example/EmbeddingV1Activity.java deleted file mode 100644 index e6ab004fccf6..000000000000 --- a/packages/flutter_plugin_android_lifecycle/example/android/app/src/main/java/io/flutter/plugins/flutter_plugin_android_lifecycle_example/EmbeddingV1Activity.java +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.flutter_plugin_android_lifecycle_example; - -import android.os.Bundle; -import dev.flutter.plugins.integration_test.IntegrationTestPlugin; -import io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - IntegrationTestPlugin.registerWith( - registrarFor("dev.flutter.plugins.integration_test.IntegrationTestPlugin")); - FlutterAndroidLifecyclePlugin.registerWith( - registrarFor( - "io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin")); - } -} diff --git a/packages/flutter_plugin_android_lifecycle/pubspec.yaml b/packages/flutter_plugin_android_lifecycle/pubspec.yaml index 2fefc8616868..0fc128d03e17 100644 --- a/packages/flutter_plugin_android_lifecycle/pubspec.yaml +++ b/packages/flutter_plugin_android_lifecycle/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_plugin_android_lifecycle description: Flutter plugin for accessing an Android Lifecycle within other plugins. repository: https://github.com/flutter/plugins/tree/master/packages/flutter_plugin_android_lifecycle issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_plugin_android_lifecycle%22 -version: 2.0.2 +version: 2.0.3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 04be1b915a5a..6ffec4e65cc4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,7 +1,8 @@ -## NEXT +## 2.0.7 * Add iOS unit and UI integration test targets. * Exclude arm64 simulators in example app. +* Remove references to the Android V1 embedding. ## 2.0.6 diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/EmbeddingV1ActivityTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/EmbeddingV1ActivityTest.java deleted file mode 100644 index 9da7185b8ace..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.googlemaps; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import io.flutter.plugins.googlemapsexample.*; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/AndroidManifest.xml index 0ff45c3cb3ac..815074bfad96 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/AndroidManifest.xml @@ -4,10 +4,7 @@ - + @@ -28,13 +25,6 @@ - - diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/java/io/flutter/plugins/googlemapsexample/EmbeddingV1Activity.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/java/io/flutter/plugins/googlemapsexample/EmbeddingV1Activity.java deleted file mode 100644 index cecf76a690e0..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/main/java/io/flutter/plugins/googlemapsexample/EmbeddingV1Activity.java +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.googlemapsexample; - -import android.os.Bundle; -import dev.flutter.plugins.integration_test.IntegrationTestPlugin; -import io.flutter.plugins.googlemaps.GoogleMapsPlugin; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - GoogleMapsPlugin.registerWith(registrarFor("io.flutter.plugins.googlemaps.GoogleMapsPlugin")); - IntegrationTestPlugin.registerWith( - registrarFor("dev.flutter.plugins.integration_test.IntegrationTestPlugin")); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 0d7475857b31..c784e9a37a94 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.0.6 +version: 2.0.7 environment: sdk: '>=2.12.0 <3.0.0' diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 186a1d39a223..2602e98be2a0 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.0.6 + +* Remove references to the Android V1 embedding. + ## 5.0.5 * Add iOS unit and UI integration test targets. diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/main/AndroidManifest.xml b/packages/google_sign_in/google_sign_in/example/android/app/src/main/AndroidManifest.xml index df80f829c1e7..22a34d7218f7 100644 --- a/packages/google_sign_in/google_sign_in/example/android/app/src/main/AndroidManifest.xml +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/main/AndroidManifest.xml @@ -14,12 +14,6 @@ - - diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1Activity.java b/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1Activity.java deleted file mode 100644 index f61bb72ba9da..000000000000 --- a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1Activity.java +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.googlesigninexample; - -import android.os.Bundle; -import io.flutter.plugins.googlesignin.GoogleSignInPlugin; -import io.flutter.view.FlutterMain; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - FlutterMain.startInitialization(this); - super.onCreate(savedInstanceState); - GoogleSignInPlugin.registerWith(registrarFor("io.flutter.plugins.googlesignin")); - } -} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1ActivityTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index cfd2fcec9ec3..000000000000 --- a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.googlesigninexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index 14f7d8901301..bbcdbc91d71e 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.0.5 +version: 5.0.6 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/image_picker/image_picker/example/android/app/src/main/AndroidManifest.xml b/packages/image_picker/image_picker/example/android/app/src/main/AndroidManifest.xml index 597abd9b81ab..543fca922e1b 100755 --- a/packages/image_picker/image_picker/example/android/app/src/main/AndroidManifest.xml +++ b/packages/image_picker/image_picker/example/android/app/src/main/AndroidManifest.xml @@ -14,13 +14,6 @@ - - diff --git a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1Activity.java b/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1Activity.java deleted file mode 100644 index b9d2808a4486..000000000000 --- a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1Activity.java +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.imagepickerexample; - -import android.os.Bundle; -import io.flutter.plugins.imagepicker.ImagePickerPlugin; -import io.flutter.plugins.videoplayer.VideoPlayerPlugin; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ImagePickerPlugin.registerWith( - registrarFor("io.flutter.plugins.imagepicker.ImagePickerPlugin")); - VideoPlayerPlugin.registerWith( - registrarFor("io.flutter.plugins.videoplayer.VideoPlayerPlugin")); - } -} diff --git a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1ActivityTest.java b/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index 7d790563abae..000000000000 --- a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.imagepickerexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 52bbff52bef0..228fcddb6370 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.7 + +* Remove references to the Android V1 embedding. + ## 1.0.6 * Added import flutter foundation dependency in README.md to be able to use `defaultTargetPlatform`. diff --git a/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/AndroidManifest.xml b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/AndroidManifest.xml index a17382b97d83..027375c09e04 100644 --- a/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/AndroidManifest.xml +++ b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/AndroidManifest.xml @@ -6,32 +6,7 @@ to allow setting breakpoints, to provide hot reload, etc. --> - - - - - - - + rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index 554a07b0bd30..a37ae07baa86 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.6 +version: 1.0.7 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml index a17382b97d83..1185a05b3530 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/AndroidManifest.xml @@ -6,32 +6,9 @@ to allow setting breakpoints, to provide hot reload, etc. --> - - - - - - rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/local_auth/CHANGELOG.md b/packages/local_auth/CHANGELOG.md index a97c4b47b288..c33fa7778b94 100644 --- a/packages/local_auth/CHANGELOG.md +++ b/packages/local_auth/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.7 + +* Remove references to the Android V1 embedding. + ## 1.1.6 * Migrate maven repository from jcenter to mavenCentral. diff --git a/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/EmbeddingV1ActivityTest.java b/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/EmbeddingV1ActivityTest.java deleted file mode 100644 index 696fc493c6b8..000000000000 --- a/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.localauth; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import io.flutter.plugins.localauthexample.EmbeddingV1Activity; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/local_auth/example/android/app/src/main/AndroidManifest.xml b/packages/local_auth/example/android/app/src/main/AndroidManifest.xml index 1425d9c6ab62..8c091772107a 100644 --- a/packages/local_auth/example/android/app/src/main/AndroidManifest.xml +++ b/packages/local_auth/example/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ - + - - diff --git a/packages/local_auth/example/android/app/src/main/java/io/flutter/plugins/localauthexample/EmbeddingV1Activity.java b/packages/local_auth/example/android/app/src/main/java/io/flutter/plugins/localauthexample/EmbeddingV1Activity.java deleted file mode 100644 index c3fc8d47b3a4..000000000000 --- a/packages/local_auth/example/android/app/src/main/java/io/flutter/plugins/localauthexample/EmbeddingV1Activity.java +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.localauthexample; - -import android.os.Bundle; -import dev.flutter.plugins.integration_test.IntegrationTestPlugin; -import io.flutter.app.FlutterFragmentActivity; -import io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin; -import io.flutter.plugins.localauth.LocalAuthPlugin; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends FlutterFragmentActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - IntegrationTestPlugin.registerWith( - registrarFor("dev.flutter.plugins.integration_test.IntegrationTestPlugin")); - FlutterAndroidLifecyclePlugin.registerWith( - registrarFor( - "io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin")); - LocalAuthPlugin.registerWith(registrarFor("io.flutter.plugins.localauth.LocalAuthPlugin")); - } -} diff --git a/packages/local_auth/pubspec.yaml b/packages/local_auth/pubspec.yaml index f50492381586..8a31b2f7d501 100644 --- a/packages/local_auth/pubspec.yaml +++ b/packages/local_auth/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Android and iOS devices to allow local authentication via fingerprint, touch ID, face ID, passcode, pin, or pattern. repository: https://github.com/flutter/plugins/tree/master/packages/local_auth issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+local_auth%22 -version: 1.1.6 +version: 1.1.7 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/package_info/CHANGELOG.md b/packages/package_info/CHANGELOG.md index 96697dd220e6..2ec20b3fe775 100644 --- a/packages/package_info/CHANGELOG.md +++ b/packages/package_info/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove references to the Android v1 embedding. + ## 2.0.2 * Update README to point to Plus Plugins version. diff --git a/packages/package_info/example/android/app/src/main/AndroidManifest.xml b/packages/package_info/example/android/app/src/main/AndroidManifest.xml index e4d033e8d8dd..f5544ce31f9f 100644 --- a/packages/package_info/example/android/app/src/main/AndroidManifest.xml +++ b/packages/package_info/example/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ - + rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/path_provider/path_provider/example/android/app/src/main/AndroidManifest.xml b/packages/path_provider/path_provider/example/android/app/src/main/AndroidManifest.xml index ec8e31f5172b..df8cee7bc3be 100644 --- a/packages/path_provider/path_provider/example/android/app/src/main/AndroidManifest.xml +++ b/packages/path_provider/path_provider/example/android/app/src/main/AndroidManifest.xml @@ -3,13 +3,7 @@ - - - + =2.12.0 <3.0.0" diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index b917dcc85db0..4f8943845cf7 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0+4 + +* Remove references to the Android V1 embedding. + ## 0.6.0+3 * Added a `const` constructor for the `QuickActions` class, so the plugin will behave as documented in the sample code mentioned in the [README.md](https://github.com/flutter/plugins/blob/59e16a556e273c2d69189b2dcdfa92d101ea6408/packages/quick_actions/quick_actions/README.md). diff --git a/packages/quick_actions/quick_actions/example/android/app/src/main/AndroidManifest.xml b/packages/quick_actions/quick_actions/example/android/app/src/main/AndroidManifest.xml index 56c924e5c8b5..4f384b7c6b13 100644 --- a/packages/quick_actions/quick_actions/example/android/app/src/main/AndroidManifest.xml +++ b/packages/quick_actions/quick_actions/example/android/app/src/main/AndroidManifest.xml @@ -3,30 +3,20 @@ - - + - - - - - - - + + + + + + diff --git a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1Activity.java b/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1Activity.java deleted file mode 100644 index d85ead3b4e36..000000000000 --- a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1Activity.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.quickactionsexample; - -import android.os.Bundle; -import io.flutter.plugins.quickactions.QuickActionsPlugin; - -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - QuickActionsPlugin.registerWith( - registrarFor("io.flutter.plugins.quickactions.QuickActionsPlugin")); - } -} diff --git a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1ActivityTest.java b/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index a7fab3f052a4..000000000000 --- a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.quickactionsexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/quick_actions/quick_actions/pubspec.yaml b/packages/quick_actions/quick_actions/pubspec.yaml index 2a4fb0c634e0..657c2f001a83 100644 --- a/packages/quick_actions/quick_actions/pubspec.yaml +++ b/packages/quick_actions/quick_actions/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for creating shortcuts on home screen, also known as Quick Actions on iOS and App Shortcuts on Android. repository: https://github.com/flutter/plugins/tree/master/packages/quick_actions issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+quick_actions%22 -version: 0.6.0+3 +version: 0.6.0+4 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/sensors/CHANGELOG.md b/packages/sensors/CHANGELOG.md index d7bf66d432a6..5ac0943333fa 100644 --- a/packages/sensors/CHANGELOG.md +++ b/packages/sensors/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove references to the Android V1 embedding. + ## 2.0.3 * Update README to point to Plus Plugins version. diff --git a/packages/sensors/example/android/app/src/main/AndroidManifest.xml b/packages/sensors/example/android/app/src/main/AndroidManifest.xml index 5c12a301b623..ea3155cb9722 100644 --- a/packages/sensors/example/android/app/src/main/AndroidManifest.xml +++ b/packages/sensors/example/android/app/src/main/AndroidManifest.xml @@ -3,14 +3,7 @@ - - - + rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/share/CHANGELOG.md b/packages/share/CHANGELOG.md index a5e45110ebeb..9074f59f05b7 100644 --- a/packages/share/CHANGELOG.md +++ b/packages/share/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove references to the Android V1 embedding. + ## 2.0.4 * Update README to point to Plus Plugins version. diff --git a/packages/share/example/android/app/src/main/AndroidManifest.xml b/packages/share/example/android/app/src/main/AndroidManifest.xml index 350fdaf5839a..d1f1ce953e3a 100644 --- a/packages/share/example/android/app/src/main/AndroidManifest.xml +++ b/packages/share/example/android/app/src/main/AndroidManifest.xml @@ -3,14 +3,7 @@ - - - + rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 1dcf7a1582a8..dc67a2142ec2 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.0.10 + +* Remove references to the Android v1 embedding. + ## 6.0.9 * Silenced warnings that may occur during build when using a very diff --git a/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/EmbeddingV1ActivityTest.java b/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index 4fb52708b9eb..000000000000 --- a/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.urllauncherexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml b/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml index d6753c9bbdbc..918c29ee2dca 100644 --- a/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml +++ b/packages/url_launcher/url_launcher/example/android/app/src/main/AndroidManifest.xml @@ -19,23 +19,9 @@ - - - + android:label="url_launcher_example"> =2.12.0 <3.0.0" diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index b9f029b31454..ec61f87f5086 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.11 + +* Remove references to the Android V1 embedding. + ## 2.1.10 * Ensure video pauses correctly when it finishes. diff --git a/packages/video_player/video_player/example/android/app/src/main/AndroidManifest.xml b/packages/video_player/video_player/example/android/app/src/main/AndroidManifest.xml index 3ad2e146c2e1..a2574c90d7d9 100644 --- a/packages/video_player/video_player/example/android/app/src/main/AndroidManifest.xml +++ b/packages/video_player/video_player/example/android/app/src/main/AndroidManifest.xml @@ -4,20 +4,7 @@ - - - =2.12.0 <3.0.0" diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 46f5e045ddd8..4ffdb08928c2 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.11 + +* Remove references to the Android V1 embedding. + ## 2.0.10 * Fix keyboard issues link in the README. diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index 56691d2fc82a..000000000000 --- a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/EmbeddingV1ActivityTest.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutterexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") -public class EmbeddingV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbeddingV1Activity.class); -} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml index 02f270fb9c49..945e47c29e82 100644 --- a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,8 @@ - - + android:label="webview_flutter_example"> diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index 88ab4ad7927e..2f00071e772e 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.10 +version: 2.0.11 environment: sdk: ">=2.12.0 <3.0.0" From 0f266188d9beeb0abf75c820f991a7a192c5a203 Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Fri, 23 Jul 2021 14:48:55 -0700 Subject: [PATCH 151/364] Remove remaining V1 embedding references (#4189) --- packages/battery/battery/CHANGELOG.md | 4 ++++ .../battery/EmbedderV1ActivityTest.java | 17 --------------- .../android/app/src/main/AndroidManifest.xml | 7 ------- .../batteryexample/EmbedderV1Activity.java | 20 ------------------ .../EmbedderV1ActivityTest.java | 17 --------------- .../android/app/src/main/AndroidManifest.xml | 6 ------ .../EmbedderV1Activity.java | 21 ------------------- 7 files changed, 4 insertions(+), 88 deletions(-) delete mode 100644 packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/EmbedderV1ActivityTest.java delete mode 100644 packages/battery/battery/example/android/app/src/main/java/io/flutter/plugins/batteryexample/EmbedderV1Activity.java delete mode 100644 packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/EmbedderV1ActivityTest.java delete mode 100644 packages/package_info/example/android/app/src/main/java/io/flutter/plugins/packageinfoexample/EmbedderV1Activity.java diff --git a/packages/battery/battery/CHANGELOG.md b/packages/battery/battery/CHANGELOG.md index b4f0cef94edb..8590e646564e 100644 --- a/packages/battery/battery/CHANGELOG.md +++ b/packages/battery/battery/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Remove references to the Android v1 embedding. + ## 2.0.3 * Update README to point to Plus Plugins version. diff --git a/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/EmbedderV1ActivityTest.java b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/EmbedderV1ActivityTest.java deleted file mode 100644 index c939be4281da..000000000000 --- a/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/EmbedderV1ActivityTest.java +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.batteryexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -public class EmbedderV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbedderV1Activity.class); -} diff --git a/packages/battery/battery/example/android/app/src/main/AndroidManifest.xml b/packages/battery/battery/example/android/app/src/main/AndroidManifest.xml index d44a8ac5757a..11feb41de96a 100644 --- a/packages/battery/battery/example/android/app/src/main/AndroidManifest.xml +++ b/packages/battery/battery/example/android/app/src/main/AndroidManifest.xml @@ -15,13 +15,6 @@ - - diff --git a/packages/battery/battery/example/android/app/src/main/java/io/flutter/plugins/batteryexample/EmbedderV1Activity.java b/packages/battery/battery/example/android/app/src/main/java/io/flutter/plugins/batteryexample/EmbedderV1Activity.java deleted file mode 100644 index 2b9e538bbe47..000000000000 --- a/packages/battery/battery/example/android/app/src/main/java/io/flutter/plugins/batteryexample/EmbedderV1Activity.java +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.batteryexample; - -import android.os.Bundle; -import dev.flutter.plugins.integration_test.IntegrationTestPlugin; -import io.flutter.app.FlutterActivity; -import io.flutter.plugins.battery.BatteryPlugin; - -public class EmbedderV1Activity extends FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - BatteryPlugin.registerWith(registrarFor("io.flutter.plugins.battery.BatteryPlugin")); - IntegrationTestPlugin.registerWith( - registrarFor("dev.flutter.plugins.integration_test.IntegrationTestPlugin")); - } -} diff --git a/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/EmbedderV1ActivityTest.java b/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/EmbedderV1ActivityTest.java deleted file mode 100644 index 8d3b0b6c6cad..000000000000 --- a/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/EmbedderV1ActivityTest.java +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.packageinfoexample; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -public class EmbedderV1ActivityTest { - @Rule - public ActivityTestRule rule = - new ActivityTestRule<>(EmbedderV1Activity.class); -} diff --git a/packages/package_info/example/android/app/src/main/AndroidManifest.xml b/packages/package_info/example/android/app/src/main/AndroidManifest.xml index f5544ce31f9f..efb42ac02c5c 100644 --- a/packages/package_info/example/android/app/src/main/AndroidManifest.xml +++ b/packages/package_info/example/android/app/src/main/AndroidManifest.xml @@ -14,12 +14,6 @@ - - diff --git a/packages/package_info/example/android/app/src/main/java/io/flutter/plugins/packageinfoexample/EmbedderV1Activity.java b/packages/package_info/example/android/app/src/main/java/io/flutter/plugins/packageinfoexample/EmbedderV1Activity.java deleted file mode 100644 index ded5f348c506..000000000000 --- a/packages/package_info/example/android/app/src/main/java/io/flutter/plugins/packageinfoexample/EmbedderV1Activity.java +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.packageinfoexample; - -import android.os.Bundle; -import dev.flutter.plugins.integration_test.IntegrationTestPlugin; -import io.flutter.app.FlutterActivity; -import io.flutter.plugins.packageinfo.PackageInfoPlugin; - -public class EmbedderV1Activity extends FlutterActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - PackageInfoPlugin.registerWith( - registrarFor("io.flutter.plugins.packageinfo.PackageInfoPlugin")); - IntegrationTestPlugin.registerWith( - registrarFor("dev.flutter.plugins.integration_test.IntegrationTestPlugin")); - } -} From 400fa202592a4c6021789a91b029de904c4c6ada Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Sat, 24 Jul 2021 02:31:06 +0200 Subject: [PATCH 152/364] [camera_web] Add `initializeCamera` implementation (#4186) --- .../example/integration_test/camera_test.dart | 52 ++++++- .../integration_test/camera_web_test.dart | 143 ++++++++++++++---- .../integration_test/helpers/mocks.dart | 23 +++ .../camera/camera_web/lib/src/camera.dart | 25 +++ .../camera/camera_web/lib/src/camera_web.dart | 56 ++++++- 5 files changed, 260 insertions(+), 39 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 0f1dcf7049d9..6eeed23ecf56 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:html'; +import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; @@ -28,13 +29,7 @@ void main() { navigator = MockNavigator(); mediaDevices = MockMediaDevices(); - final videoElement = VideoElement() - ..src = - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' - ..preload = 'true' - ..width = 10 - ..height = 10; - + final videoElement = getVideoElementWithBlankStream(Size(10, 10)); mediaStream = videoElement.captureStream(); when(() => window.navigator).thenReturn(navigator); @@ -469,6 +464,49 @@ void main() { }); }); + group('getVideoSize', () { + testWidgets( + 'returns a size ' + 'based on the first video track settings', (tester) async { + const videoSize = Size(1280, 720); + + final videoElement = getVideoElementWithBlankStream(videoSize); + mediaStream = videoElement.captureStream(); + + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + expect( + await camera.getVideoSize(), + equals(videoSize), + ); + }); + + testWidgets( + 'returns Size.zero ' + 'if the camera is missing video tracks', (tester) async { + // Create a video stream with no video tracks. + final videoElement = VideoElement(); + mediaStream = videoElement.captureStream(); + + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + expect( + await camera.getVideoSize(), + equals(Size.zero), + ); + }); + }); + group('dispose', () { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index eef17ecfdff9..d5e1835391ad 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -5,6 +5,7 @@ import 'dart:html'; import 'dart:ui'; +import 'package:async/async.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/camera_web.dart'; import 'package:camera_web/src/camera.dart'; @@ -33,13 +34,8 @@ void main() { window = MockWindow(); navigator = MockNavigator(); mediaDevices = MockMediaDevices(); - videoElement = VideoElement() - ..src = - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' - ..preload = 'true' - ..width = 10 - ..height = 10 - ..crossOrigin = 'anonymous'; + + videoElement = getVideoElementWithBlankStream(Size(10, 10)); cameraSettings = MockCameraSettings(); @@ -327,21 +323,18 @@ void main() { const ultraHighResolutionSize = Size(3840, 2160); const maxResolutionSize = Size(3840, 2160); - late CameraDescription cameraDescription; - late CameraMetadata cameraMetadata; - - setUp(() { - cameraDescription = CameraDescription( - name: 'name', - lensDirection: CameraLensDirection.front, - sensorOrientation: 0, - ); + final cameraDescription = CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ); - cameraMetadata = CameraMetadata( - deviceId: 'deviceId', - facingMode: 'user', - ); + final cameraMetadata = CameraMetadata( + deviceId: 'deviceId', + facingMode: 'user', + ); + setUp(() { // Add metadata for the camera description. (CameraPlatform.instance as CameraPlugin) .camerasMetadata[cameraDescription] = cameraMetadata; @@ -434,11 +427,38 @@ void main() { }); }); - testWidgets('initializeCamera throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.initializeCamera(cameraId), - throwsUnimplementedError, - ); + group('initializeCamera', () { + testWidgets( + 'throws CameraException ' + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets('initializes and plays the camera', (tester) async { + final camera = MockCamera(); + + when(camera.getVideoSize).thenAnswer((_) => Future.value(Size(10, 10))); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.initializeCamera(cameraId); + + verify(camera.initialize).called(1); + verify(camera.play).called(1); + }); }); testWidgets('lockCaptureOrientation throws UnimplementedError', @@ -628,13 +648,78 @@ void main() { ); }); + group('getCamera', () { + testWidgets('returns the correct camera', (tester) async { + final camera = Camera(textureId: cameraId, window: window); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + (CameraPlatform.instance as CameraPlugin).getCamera(cameraId), + equals(camera), + ); + }); + + testWidgets( + 'throws CameraException ' + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => (CameraPlatform.instance as CameraPlugin).getCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + }); + group('events', () { - testWidgets('onCameraInitialized throws UnimplementedError', - (tester) async { + testWidgets( + 'onCameraInitialized emits a CameraInitializedEvent ' + 'on initializeCamera', (tester) async { + // Mock the camera to use a blank video stream of size 1280x720. + const videoSize = Size(1280, 720); + + videoElement = getVideoElementWithBlankStream(videoSize); + + when( + () => mediaDevices.getUserMedia(any()), + ).thenAnswer((_) async => videoElement.captureStream()); + + final camera = Camera( + textureId: cameraId, + window: window, + ); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final Stream eventStream = + CameraPlatform.instance.onCameraInitialized(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + expect( - () => CameraPlatform.instance.onCameraInitialized(cameraId), - throwsUnimplementedError, + await streamQueue.next, + CameraInitializedEvent( + cameraId, + videoSize.width, + videoSize.height, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), ); + + await streamQueue.cancel(); }); testWidgets('onCameraResolutionChanged throws UnimplementedError', diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 3702aee8e184..fa627ca0b7e6 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -3,7 +3,9 @@ // found in the LICENSE file. import 'dart:html'; +import 'dart:ui'; +import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:mocktail/mocktail.dart'; @@ -17,6 +19,8 @@ class MockCameraSettings extends Mock implements CameraSettings {} class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} +class MockCamera extends Mock implements Camera {} + /// A fake [MediaStream] that returns the provided [_videoTracks]. class FakeMediaStream extends Fake implements MediaStream { FakeMediaStream(this._videoTracks); @@ -54,3 +58,22 @@ class FakeDomException extends Fake implements DomException { @override String get name => _name; } + +/// Returns a video element with a blank stream of size [videoSize]. +/// +/// Can be used to mock a video stream: +/// ```dart +/// final videoElement = getVideoElementWithBlankStream(Size(100, 100)); +/// final videoStream = videoElement.captureStream(); +/// ``` +VideoElement getVideoElementWithBlankStream(Size videoSize) { + final canvasElement = CanvasElement( + width: videoSize.width.toInt(), + height: videoSize.height.toInt(), + )..context2D.fillRect(0, 0, videoSize.width, videoSize.height); + + final videoElement = VideoElement() + ..srcObject = canvasElement.captureStream(); + + return videoElement; +} diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 41692d548882..334f117be274 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:html' as html; +import 'dart:ui'; import 'shims/dart_ui.dart' as ui; import 'package:camera_platform_interface/camera_platform_interface.dart'; @@ -171,6 +172,30 @@ class Camera { return XFile(html.Url.createObjectUrl(blob)); } + /// Returns a size of the camera video based on its first video track size. + /// + /// Returns [Size.zero] if the camera is missing a video track or + /// the video track does not include the width or height setting. + Future getVideoSize() async { + final videoTracks = videoElement.srcObject?.getVideoTracks() ?? []; + + if (videoTracks.isEmpty) { + return Size.zero; + } + + final defaultVideoTrack = videoTracks.first; + final defaultVideoTrackSettings = defaultVideoTrack.getSettings(); + + final width = defaultVideoTrackSettings['width']; + final height = defaultVideoTrackSettings['height']; + + if (width != null && height != null) { + return Size(width, height); + } else { + return Size.zero; + } + } + /// Disposes the camera by stopping the camera stream /// and reloading the camera source. void dispose() { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 80ab13d37d13..e58572e50ee4 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -13,6 +13,7 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:stream_transform/stream_transform.dart'; /// The web implementation of [CameraPlatform]. /// @@ -42,6 +43,18 @@ class CameraPlugin extends CameraPlatform { @visibleForTesting final camerasMetadata = {}; + /// The controller used to broadcast different camera events. + /// + /// It is `broadcast` as multiple controllers may subscribe + /// to different stream views of this controller. + @visibleForTesting + final cameraEventStreamController = StreamController.broadcast(); + + /// Returns a stream of camera events for the given [cameraId]. + Stream _cameraEvents(int cameraId) => + cameraEventStreamController.stream + .where((event) => event.cameraId == cameraId); + /// The current browser window used to access media devices. @visibleForTesting html.Window? window = html.window; @@ -186,14 +199,34 @@ class CameraPlugin extends CameraPlatform { @override Future initializeCamera( int cameraId, { + // The image format group is currently not supported. ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, - }) { - throw UnimplementedError('initializeCamera() is not implemented.'); + }) async { + final camera = getCamera(cameraId); + + await camera.initialize(); + await camera.play(); + + final cameraSize = await camera.getVideoSize(); + + cameraEventStreamController.add( + CameraInitializedEvent( + cameraId, + cameraSize.width, + cameraSize.height, + // TODO(camera_web): Add support for exposure mode and point (https://github.com/flutter/flutter/issues/86857). + ExposureMode.auto, + false, + // TODO(camera_web): Add support for focus mode and point (https://github.com/flutter/flutter/issues/86858). + FocusMode.auto, + false, + ), + ); } @override Stream onCameraInitialized(int cameraId) { - throw UnimplementedError('onCameraInitialized() is not implemented.'); + return _cameraEvents(cameraId).whereType(); } @override @@ -348,4 +381,21 @@ class CameraPlugin extends CameraPlatform { return mediaDevices.getUserMedia(cameraOptions.toJson()); } + + /// Returns a camera for the given [cameraId]. + /// + /// Throws a [CameraException] if the camera does not exist. + @visibleForTesting + Camera getCamera(int cameraId) { + final camera = cameras[cameraId]; + + if (camera == null) { + throw CameraException( + CameraErrorCodes.notFound, + 'No camera found for the given camera id $cameraId.', + ); + } + + return camera; + } } From 43ca2627524bff1f528e63731055fae9de1d85c9 Mon Sep 17 00:00:00 2001 From: Mahesh Jamdade <31410839+maheshmnj@users.noreply.github.com> Date: Sat, 24 Jul 2021 11:03:05 +0530 Subject: [PATCH 153/364] [video player]: update the url in the readme example (#4081) --- packages/video_player/video_player/CHANGELOG.md | 4 ++++ packages/video_player/video_player/README.md | 4 ++-- packages/video_player/video_player/pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index ec61f87f5086..bfed1615f8a6 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.12 + +* Update the video url in the readme code sample + ## 2.1.11 * Remove references to the Android V1 embedding. diff --git a/packages/video_player/video_player/README.md b/packages/video_player/video_player/README.md index 7140527afb9f..a1d3d935e71c 100644 --- a/packages/video_player/video_player/README.md +++ b/packages/video_player/video_player/README.md @@ -75,7 +75,7 @@ class _VideoAppState extends State { void initState() { super.initState(); _controller = VideoPlayerController.network( - 'https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4') + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4') ..initialize().then((_) { // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed. setState(() {}); @@ -129,7 +129,7 @@ This is not complete as of now. You can contribute to this section by [opening a ### Playback speed You can set the playback speed on your `_controller` (instance of `VideoPlayerController`) by -calling `_controller.setPlaybackSpeed`. `setPlaybackSpeed` takes a `double` speed value indicating +calling `_controller.setPlaybackSpeed`. `setPlaybackSpeed` takes a `double` speed value indicating the rate of playback for your video. For example, when given a value of `2.0`, your video will play at 2x the regular playback speed and so on. diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index cd444942ffe6..960f0c6ce63a 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.11 +version: 2.1.12 environment: sdk: ">=2.12.0 <3.0.0" From 7d49fd469a23e62dcc550f156d82243dee6533ff Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Sat, 24 Jul 2021 16:06:34 -0700 Subject: [PATCH 154/364] Make java-test output more useful (#4184) --- packages/android_alarm_manager/android/build.gradle | 13 +++++++++++++ packages/android_intent/android/build.gradle | 10 ++++++++++ packages/battery/battery/android/build.gradle | 13 +++++++++++++ packages/camera/camera/android/build.gradle | 9 +++++++++ .../connectivity/connectivity/android/build.gradle | 13 +++++++++++++ .../device_info/device_info/android/build.gradle | 13 +++++++++++++ packages/espresso/android/build.gradle | 13 +++++++++++++ .../android/build.gradle | 13 +++++++++++++ .../google_maps_flutter/android/build.gradle | 13 +++++++++++++ .../google_sign_in/android/build.gradle | 13 +++++++++++++ .../image_picker/image_picker/android/build.gradle | 13 +++++++++++++ .../in_app_purchase_android/android/build.gradle | 13 +++++++++++++ packages/local_auth/android/build.gradle | 13 +++++++++++++ packages/package_info/android/build.gradle | 13 +++++++++++++ .../path_provider/android/build.gradle | 13 +++++++++++++ .../quick_actions/android/build.gradle | 13 +++++++++++++ packages/sensors/android/build.gradle | 13 +++++++++++++ packages/share/android/build.gradle | 13 +++++++++++++ .../shared_preferences/android/build.gradle | 13 +++++++++++++ .../url_launcher/url_launcher/android/build.gradle | 10 ++++++++++ .../video_player/video_player/android/build.gradle | 13 +++++++++++++ .../webview_flutter/android/build.gradle | 13 +++++++++++++ .../wifi_info_flutter/android/build.gradle | 13 +++++++++++++ script/tool/lib/src/native_test_command.dart | 2 +- script/tool/test/native_test_command_test.dart | 6 +++--- 25 files changed, 293 insertions(+), 4 deletions(-) diff --git a/packages/android_alarm_manager/android/build.gradle b/packages/android_alarm_manager/android/build.gradle index 52a07082dded..be741097f362 100644 --- a/packages/android_alarm_manager/android/build.gradle +++ b/packages/android_alarm_manager/android/build.gradle @@ -39,6 +39,19 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/android_intent/android/build.gradle b/packages/android_intent/android/build.gradle index adf53f94393c..b0238b7db4f3 100644 --- a/packages/android_intent/android/build.gradle +++ b/packages/android_intent/android/build.gradle @@ -36,8 +36,18 @@ android { lintOptions { disable 'InvalidPackage' } + + testOptions { unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } } } diff --git a/packages/battery/battery/android/build.gradle b/packages/battery/battery/android/build.gradle index 28d561f05652..1e484897c2ad 100644 --- a/packages/battery/battery/android/build.gradle +++ b/packages/battery/battery/android/build.gradle @@ -31,4 +31,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle index 65c6d26edb49..6ceed97c9a17 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -40,9 +40,18 @@ android { sourceCompatibility = '1.8' targetCompatibility = '1.8' } + + testOptions { unitTests.includeAndroidResources = true unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } } } diff --git a/packages/connectivity/connectivity/android/build.gradle b/packages/connectivity/connectivity/android/build.gradle index afd7d9f0a977..53a390bd74f0 100644 --- a/packages/connectivity/connectivity/android/build.gradle +++ b/packages/connectivity/connectivity/android/build.gradle @@ -36,4 +36,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/device_info/device_info/android/build.gradle b/packages/device_info/device_info/android/build.gradle index 9b1f6470a37d..51ec2a7fb567 100644 --- a/packages/device_info/device_info/android/build.gradle +++ b/packages/device_info/device_info/android/build.gradle @@ -31,4 +31,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/espresso/android/build.gradle b/packages/espresso/android/build.gradle index 74988a50a3b9..8cd54811afa0 100644 --- a/packages/espresso/android/build.gradle +++ b/packages/espresso/android/build.gradle @@ -31,6 +31,19 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/flutter_plugin_android_lifecycle/android/build.gradle b/packages/flutter_plugin_android_lifecycle/android/build.gradle index cf34c98aaf3b..ba3a54b235e6 100644 --- a/packages/flutter_plugin_android_lifecycle/android/build.gradle +++ b/packages/flutter_plugin_android_lifecycle/android/build.gradle @@ -36,6 +36,19 @@ android { dependencies { implementation "androidx.annotation:annotation:1.1.0" } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/google_maps_flutter/google_maps_flutter/android/build.gradle b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle index 1fabe10216c3..1433d3559b77 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle @@ -44,6 +44,19 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/google_sign_in/google_sign_in/android/build.gradle b/packages/google_sign_in/google_sign_in/android/build.gradle index c95ba17c10d7..7d1825defa84 100644 --- a/packages/google_sign_in/google_sign_in/android/build.gradle +++ b/packages/google_sign_in/google_sign_in/android/build.gradle @@ -31,6 +31,19 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/image_picker/image_picker/android/build.gradle b/packages/image_picker/image_picker/android/build.gradle index e21b7f1738b4..e0d51d8dd1f5 100755 --- a/packages/image_picker/image_picker/android/build.gradle +++ b/packages/image_picker/image_picker/android/build.gradle @@ -36,4 +36,17 @@ android { implementation 'androidx.annotation:annotation:1.0.0' implementation 'androidx.exifinterface:exifinterface:1.3.0' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle index eeac168068f7..349f9eeb734c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle @@ -35,6 +35,19 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/local_auth/android/build.gradle b/packages/local_auth/android/build.gradle index 4b0995e65946..4fcb77cf6c98 100644 --- a/packages/local_auth/android/build.gradle +++ b/packages/local_auth/android/build.gradle @@ -31,6 +31,19 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/package_info/android/build.gradle b/packages/package_info/android/build.gradle index 9144e6aade58..d2846f260556 100644 --- a/packages/package_info/android/build.gradle +++ b/packages/package_info/android/build.gradle @@ -31,4 +31,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/path_provider/path_provider/android/build.gradle b/packages/path_provider/path_provider/android/build.gradle index 6df60f0a3a63..db2c79c15796 100644 --- a/packages/path_provider/path_provider/android/build.gradle +++ b/packages/path_provider/path_provider/android/build.gradle @@ -37,6 +37,19 @@ android { targetCompatibility 1.8 } } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } dependencies { diff --git a/packages/quick_actions/quick_actions/android/build.gradle b/packages/quick_actions/quick_actions/android/build.gradle index 00de9453f86d..038f9e99048a 100644 --- a/packages/quick_actions/quick_actions/android/build.gradle +++ b/packages/quick_actions/quick_actions/android/build.gradle @@ -31,4 +31,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/sensors/android/build.gradle b/packages/sensors/android/build.gradle index 50b4eac981e2..a16ebd2ee459 100644 --- a/packages/sensors/android/build.gradle +++ b/packages/sensors/android/build.gradle @@ -31,4 +31,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/share/android/build.gradle b/packages/share/android/build.gradle index 231aaa653f2b..1b95bf592fb6 100644 --- a/packages/share/android/build.gradle +++ b/packages/share/android/build.gradle @@ -36,4 +36,17 @@ android { implementation 'androidx.core:core:1.3.1' implementation 'androidx.annotation:annotation:1.1.0' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/shared_preferences/shared_preferences/android/build.gradle b/packages/shared_preferences/shared_preferences/android/build.gradle index 9f7eeca84512..6a66eba508fb 100644 --- a/packages/shared_preferences/shared_preferences/android/build.gradle +++ b/packages/shared_preferences/shared_preferences/android/build.gradle @@ -43,4 +43,17 @@ android { testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-inline:3.9.0' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/url_launcher/url_launcher/android/build.gradle b/packages/url_launcher/url_launcher/android/build.gradle index a0225af4491b..5dd7e773a1ca 100644 --- a/packages/url_launcher/url_launcher/android/build.gradle +++ b/packages/url_launcher/url_launcher/android/build.gradle @@ -31,8 +31,18 @@ android { lintOptions { disable 'InvalidPackage' } + + testOptions { unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } } } diff --git a/packages/video_player/video_player/android/build.gradle b/packages/video_player/video_player/android/build.gradle index d0ee30375376..f2f18bff9798 100644 --- a/packages/video_player/video_player/android/build.gradle +++ b/packages/video_player/video_player/android/build.gradle @@ -51,4 +51,17 @@ android { testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-inline:3.9.0' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/webview_flutter/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle index 41c702f9fc56..cd1b4188a1eb 100644 --- a/packages/webview_flutter/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/webview_flutter/android/build.gradle @@ -40,4 +40,17 @@ android { testImplementation 'org.mockito:mockito-inline:3.11.1' testImplementation 'androidx.test:core:1.3.0' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle b/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle index 8a80e0ce8b6e..2b5a8a7fc209 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle +++ b/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle @@ -30,4 +30,17 @@ android { lintOptions { disable 'InvalidPackage' } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } } diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 73a435d83e1d..36b12741f2ce 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -222,7 +222,7 @@ this command. } final int exitCode = await processRunner.runAndStream( - gradleFile.path, ['testDebugUnitTest', '--info'], + gradleFile.path, ['testDebugUnitTest'], workingDir: androidDirectory); if (exitCode != 0) { printError('$exampleName tests failed.'); diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index ca28a6cff0e7..e656e2f23721 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -376,7 +376,7 @@ void main() { orderedEquals([ ProcessCall( androidFolder.childFile('gradlew').path, - const ['testDebugUnitTest', '--info'], + const ['testDebugUnitTest'], androidFolder.path, ), ]), @@ -406,7 +406,7 @@ void main() { orderedEquals([ ProcessCall( androidFolder.childFile('gradlew').path, - const ['testDebugUnitTest', '--info'], + const ['testDebugUnitTest'], androidFolder.path, ), ]), @@ -812,7 +812,7 @@ void main() { orderedEquals([ ProcessCall( androidFolder.childFile('gradlew').path, - const ['testDebugUnitTest', '--info'], + const ['testDebugUnitTest'], androidFolder.path), ProcessCall( 'xcrun', From 1dce6ab475a53cd5ae028a593e89151bfabd617a Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Mon, 26 Jul 2021 11:33:05 -0700 Subject: [PATCH 155/364] Fix webview_flutter Android integration tests and add Espresso (#4147) --- .../example/android/app/build.gradle | 2 +- .../MainActivityTest.java | 0 .../webviewflutterexample/WebViewTest.java | 23 +++++++++ .../android/app/src/debug/AndroidManifest.xml | 17 +++++++ .../android/app/src/main/AndroidManifest.xml | 2 + .../WebViewTestActivity.java | 20 ++++++++ .../webview_flutter_test.dart | 47 +++++++++++++------ .../webview_flutter/example/pubspec.yaml | 1 + .../configs/exclude_integration_android.yaml | 1 - 9 files changed, 96 insertions(+), 17 deletions(-) rename packages/webview_flutter/webview_flutter/example/android/app/src/{androidTestDebug => androidTest}/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java (100%) create mode 100644 packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java create mode 100644 packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/webview_flutter/webview_flutter/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java diff --git a/packages/webview_flutter/webview_flutter/example/android/app/build.gradle b/packages/webview_flutter/webview_flutter/example/android/app/build.gradle index 47eb97623747..9a43699afb2b 100644 --- a/packages/webview_flutter/webview_flutter/example/android/app/build.gradle +++ b/packages/webview_flutter/webview_flutter/example/android/app/build.gradle @@ -57,6 +57,6 @@ flutter { dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' - androidTestImplementation 'androidx.test:rules:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' } diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java similarity index 100% rename from packages/webview_flutter/webview_flutter/example/android/app/src/androidTestDebug/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java rename to packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java new file mode 100644 index 000000000000..0b3eeef9b6b7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutterexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.webviewflutter.WebViewFlutterPlugin; +import org.junit.Test; + +public class WebViewTest { + @Test + public void webViewPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(WebViewTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(WebViewFlutterPlugin.class)); + }); + } +} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..28792201bc36 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml index 945e47c29e82..e50fcfd9b330 100644 --- a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + controllerCompleter = Completer(); await tester.pumpWidget( @@ -36,8 +37,9 @@ void main() { final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); expect(currentUrl, 'https://flutter.dev/'); - }); + }, skip: true); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('loadUrl', (WidgetTester tester) async { final Completer controllerCompleter = Completer(); @@ -57,8 +59,9 @@ void main() { await controller.loadUrl('https://www.google.com/'); final String? currentUrl = await controller.currentUrl(); expect(currentUrl, 'https://www.google.com/'); - }); + }, skip: true); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('loadUrl with headers', (WidgetTester tester) async { final Completer controllerCompleter = Completer(); @@ -98,7 +101,7 @@ void main() { final String content = await controller .evaluateJavascript('document.documentElement.innerText'); expect(content.contains('flutter_test_header'), isTrue); - }); + }, skip: Platform.isAndroid); testWidgets('JavaScriptChannel', (WidgetTester tester) async { final Completer controllerCompleter = @@ -274,6 +277,7 @@ void main() { expect(customUserAgent2, 'Custom_User_Agent2'); }); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('use default platform userAgent after webView is rebuilt', (WidgetTester tester) async { final Completer controllerCompleter = @@ -323,7 +327,7 @@ void main() { final String customUserAgent2 = await _getUserAgent(controller); expect(customUserAgent2, defaultPlatformUserAgent); - }); + }, skip: Platform.isAndroid); group('Video playback policy', () { late String videoTestBase64; @@ -532,6 +536,7 @@ void main() { expect(fullScreen, _webviewBool(false)); }); + // allowsInlineMediaPlayback is a noop on Android, so it is skipped. testWidgets( 'Video plays full screen when allowsInlineMediaPlayback is false', (WidgetTester tester) async { @@ -581,7 +586,7 @@ void main() { String fullScreen = await controller.evaluateJavascript('isFullScreen();'); expect(fullScreen, _webviewBool(true)); - }); + }, skip: Platform.isAndroid); }); group('Audio playback policy', () { @@ -796,6 +801,7 @@ void main() { }); group('Programmatic Scroll', () { + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { final String scrollTestPage = ''' @@ -870,7 +876,7 @@ void main() { scrollPosY = await controller.getScrollY(); expect(scrollPosX, X_SCROLL * 2); expect(scrollPosY, Y_SCROLL * 2); - }); + }, skip: Platform.isAndroid); }); group('SurfaceAndroidWebView', () { @@ -882,6 +888,7 @@ void main() { WebView.platform = null; }); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { final String scrollTestPage = ''' @@ -948,8 +955,9 @@ void main() { scrollPosY = await controller.getScrollY(); expect(X_SCROLL * 2, scrollPosX); expect(Y_SCROLL * 2, scrollPosY); - }, skip: !Platform.isAndroid); + }, skip: true); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('inputs are scrolled into view when focused', (WidgetTester tester) async { final String scrollTestPage = ''' @@ -1053,7 +1061,7 @@ void main() { lastInputClientRectRelativeToViewport['right'] <= viewportRectRelativeToViewport['right'], isTrue); - }, skip: !Platform.isAndroid); + }, skip: true); }); group('NavigationDelegate', () { @@ -1272,18 +1280,20 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.evaluateJavascript('window.open("about:blank", "_blank")'); + await controller + .evaluateJavascript('window.open("https://flutter.dev/", "_blank")'); await pageLoaded.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'about:blank'); + expect(currentUrl, 'https://flutter.dev/'); }); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets( 'can open new window and go back', (WidgetTester tester) async { final Completer controllerCompleter = Completer(); - final Completer pageLoaded = Completer(); + Completer pageLoaded = Completer(); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, @@ -1301,15 +1311,22 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; + expect(controller.currentUrl(), completion('https://flutter.dev/')); + await pageLoaded.future; + pageLoaded = Completer(); + await controller - .evaluateJavascript('window.open("https://www.google.com")'); + .evaluateJavascript('window.open("https://www.google.com/")'); await pageLoaded.future; + pageLoaded = Completer(); expect(controller.currentUrl(), completion('https://www.google.com/')); + expect(controller.canGoBack(), completion(true)); await controller.goBack(); - expect(controller.currentUrl(), completion('https://www.flutter.dev')); + await pageLoaded.future; + expect(controller.currentUrl(), completion('https://flutter.dev/')); }, - skip: !Platform.isAndroid, + skip: true, ); testWidgets( diff --git a/packages/webview_flutter/webview_flutter/example/pubspec.yaml b/packages/webview_flutter/webview_flutter/example/pubspec.yaml index 3529ecc069c8..2316d7941427 100644 --- a/packages/webview_flutter/webview_flutter/example/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/example/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: path: ../ dev_dependencies: + espresso: ^0.1.0+2 flutter_test: sdk: flutter flutter_driver: diff --git a/script/configs/exclude_integration_android.yaml b/script/configs/exclude_integration_android.yaml index 9fc31ec2166a..fc34efa36ac5 100644 --- a/script/configs/exclude_integration_android.yaml +++ b/script/configs/exclude_integration_android.yaml @@ -7,7 +7,6 @@ - shared_preferences/shared_preferences - url_launcher/url_launcher - video_player/video_player -- webview_flutter # Deprecated; no plan to backfill the missing files - android_intent From 6960795eb927196973fed4494129717b12480f0f Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Mon, 26 Jul 2021 15:01:43 -0700 Subject: [PATCH 156/364] Move unit tests to Android modules (#4193) --- .../google_maps_flutter/android/build.gradle | 10 +- .../googlemaps/GoogleMapControllerTest.java | 3 + .../org.mockito.plugins.MockMaker | 1 - .../googlesignin/GoogleSignInTest.java | 175 ++++++++++++++++ .../googlesignin/GoogleSignInPluginTests.java | 189 ------------------ .../image_picker/android/build.gradle | 9 + .../plugins/imagepicker/FileUtilTest.java | 0 .../imagepicker/ImagePickerCacheTest.java | 0 .../imagepicker/ImagePickerDelegateTest.java | 0 .../imagepicker/ImagePickerPluginTest.java | 0 .../plugins/imagepicker/ImageResizerTest.java | 0 .../org.mockito.plugins.MockMaker | 0 .../src/test/resources/pngImage.png | Bin 13 files changed, 191 insertions(+), 196 deletions(-) rename packages/google_maps_flutter/google_maps_flutter/{example/android/app => android}/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java (94%) delete mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker delete mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInPluginTests.java rename packages/image_picker/image_picker/{example/android/app => android}/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java (100%) rename packages/image_picker/image_picker/{example/android/app => android}/src/test/java/io/flutter/plugins/imagepicker/ImagePickerCacheTest.java (100%) rename packages/image_picker/image_picker/{example/android/app => android}/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java (100%) rename packages/image_picker/image_picker/{example/android/app => android}/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java (100%) rename packages/image_picker/image_picker/{example/android/app => android}/src/test/java/io/flutter/plugins/imagepicker/ImageResizerTest.java (100%) rename packages/image_picker/image_picker/{example/android/app => android}/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker (100%) rename packages/image_picker/image_picker/{example/android/app => android}/src/test/resources/pngImage.png (100%) diff --git a/packages/google_maps_flutter/google_maps_flutter/android/build.gradle b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle index 1433d3559b77..6c5ea76ae61e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle @@ -38,6 +38,10 @@ android { androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:rules:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-core:3.2.4' + testImplementation 'androidx.test:core:1.2.0' + testImplementation "org.robolectric:robolectric:4.3.1" } compileOptions { @@ -45,7 +49,6 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } - testOptions { unitTests.includeAndroidResources = true unitTests.returnDefaultValues = true @@ -58,8 +61,3 @@ android { } } } - -dependencies { - testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-core:3.2.4' -} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java similarity index 94% rename from packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java rename to packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java index 2a81479988e0..6bda085caf46 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java @@ -8,6 +8,7 @@ import static org.junit.Assert.assertTrue; import android.content.Context; +import android.os.Build; import androidx.activity.ComponentActivity; import androidx.test.core.app.ApplicationProvider; import com.google.android.gms.maps.GoogleMap; @@ -19,8 +20,10 @@ import org.mockito.MockitoAnnotations; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.P) public class GoogleMapControllerTest { private Context context; diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index 1f0955d450f0..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-inline diff --git a/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java index 4e7be75aa7cf..3b6ad960f548 100644 --- a/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java +++ b/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java @@ -5,13 +5,188 @@ package io.flutter.plugins.googlesignin; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import android.app.Activity; import android.content.Context; +import android.content.Intent; +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.common.api.Scope; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; public class GoogleSignInTest { + @Mock Context mockContext; + @Mock Activity mockActivity; + @Mock PluginRegistry.Registrar mockRegistrar; + @Mock BinaryMessenger mockMessenger; + @Spy MethodChannel.Result result; + @Mock GoogleSignInWrapper mockGoogleSignIn; + @Mock GoogleSignInAccount account; + private GoogleSignInPlugin plugin; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mockRegistrar.messenger()).thenReturn(mockMessenger); + when(mockRegistrar.context()).thenReturn(mockContext); + when(mockRegistrar.activity()).thenReturn(mockActivity); + plugin = new GoogleSignInPlugin(); + plugin.initInstance(mockRegistrar.messenger(), mockRegistrar.context(), mockGoogleSignIn); + plugin.setUpRegistrar(mockRegistrar); + } + + @Test + public void requestScopes_ResultErrorIfAccountIsNull() { + MethodCall methodCall = new MethodCall("requestScopes", null); + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(null); + plugin.onMethodCall(methodCall, result); + verify(result).error("sign_in_required", "No account to grant scopes.", null); + } + + @Test + public void requestScopes_ResultTrueIfAlreadyGranted() { + HashMap> arguments = new HashMap<>(); + arguments.put("scopes", Collections.singletonList("requestedScope")); + + MethodCall methodCall = new MethodCall("requestScopes", arguments); + Scope requestedScope = new Scope("requestedScope"); + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); + when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); + when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(true); + + plugin.onMethodCall(methodCall, result); + verify(result).success(true); + } + + @Test + public void requestScopes_RequestsPermissionIfNotGranted() { + HashMap> arguments = new HashMap<>(); + arguments.put("scopes", Collections.singletonList("requestedScope")); + MethodCall methodCall = new MethodCall("requestScopes", arguments); + Scope requestedScope = new Scope("requestedScope"); + + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); + when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); + when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); + + plugin.onMethodCall(methodCall, result); + + verify(mockGoogleSignIn) + .requestPermissions(mockActivity, 53295, account, new Scope[] {requestedScope}); + } + + @Test + public void requestScopes_ReturnsFalseIfPermissionDenied() { + HashMap> arguments = new HashMap<>(); + arguments.put("scopes", Collections.singletonList("requestedScope")); + MethodCall methodCall = new MethodCall("requestScopes", arguments); + Scope requestedScope = new Scope("requestedScope"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockRegistrar).addActivityResultListener(captor.capture()); + PluginRegistry.ActivityResultListener listener = captor.getValue(); + + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); + when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); + when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); + + plugin.onMethodCall(methodCall, result); + listener.onActivityResult( + GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, + Activity.RESULT_CANCELED, + new Intent()); + + verify(result).success(false); + } + + @Test + public void requestScopes_ReturnsTrueIfPermissionGranted() { + HashMap> arguments = new HashMap<>(); + arguments.put("scopes", Collections.singletonList("requestedScope")); + MethodCall methodCall = new MethodCall("requestScopes", arguments); + Scope requestedScope = new Scope("requestedScope"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockRegistrar).addActivityResultListener(captor.capture()); + PluginRegistry.ActivityResultListener listener = captor.getValue(); + + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); + when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); + when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); + + plugin.onMethodCall(methodCall, result); + listener.onActivityResult( + GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); + + verify(result).success(true); + } + + @Test + public void requestScopes_mayBeCalledRepeatedly_ifAlreadyGranted() { + HashMap> arguments = new HashMap<>(); + arguments.put("scopes", Collections.singletonList("requestedScope")); + MethodCall methodCall = new MethodCall("requestScopes", arguments); + Scope requestedScope = new Scope("requestedScope"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockRegistrar).addActivityResultListener(captor.capture()); + PluginRegistry.ActivityResultListener listener = captor.getValue(); + + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); + when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); + when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); + + plugin.onMethodCall(methodCall, result); + listener.onActivityResult( + GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); + plugin.onMethodCall(methodCall, result); + listener.onActivityResult( + GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); + + verify(result, times(2)).success(true); + } + + @Test + public void requestScopes_mayBeCalledRepeatedly_ifNotSignedIn() { + HashMap> arguments = new HashMap<>(); + arguments.put("scopes", Collections.singletonList("requestedScope")); + MethodCall methodCall = new MethodCall("requestScopes", arguments); + Scope requestedScope = new Scope("requestedScope"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class); + verify(mockRegistrar).addActivityResultListener(captor.capture()); + PluginRegistry.ActivityResultListener listener = captor.getValue(); + + when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(null); + + plugin.onMethodCall(methodCall, result); + listener.onActivityResult( + GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); + plugin.onMethodCall(methodCall, result); + listener.onActivityResult( + GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); + + verify(result, times(2)).error("sign_in_required", "No account to grant scopes.", null); + } + @Test(expected = IllegalStateException.class) public void signInThrowsWithoutActivity() { final GoogleSignInPlugin plugin = new GoogleSignInPlugin(); diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInPluginTests.java b/packages/google_sign_in/google_sign_in/example/android/app/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInPluginTests.java deleted file mode 100644 index f1058760e2de..000000000000 --- a/packages/google_sign_in/google_sign_in/example/android/app/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInPluginTests.java +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.googlesignin; - -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import com.google.android.gms.auth.api.signin.GoogleSignInAccount; -import com.google.android.gms.common.api.Scope; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.PluginRegistry; -import io.flutter.plugin.common.PluginRegistry.ActivityResultListener; -import io.flutter.plugins.googlesignin.GoogleSignInPlugin.Delegate; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import org.junit.Before; -import org.junit.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -public class GoogleSignInPluginTests { - - @Mock Context mockContext; - @Mock Activity mockActivity; - @Mock PluginRegistry.Registrar mockRegistrar; - @Mock BinaryMessenger mockMessenger; - @Spy MethodChannel.Result result; - @Mock GoogleSignInWrapper mockGoogleSignIn; - @Mock GoogleSignInAccount account; - private GoogleSignInPlugin plugin; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - when(mockRegistrar.messenger()).thenReturn(mockMessenger); - when(mockRegistrar.context()).thenReturn(mockContext); - when(mockRegistrar.activity()).thenReturn(mockActivity); - plugin = new GoogleSignInPlugin(); - plugin.initInstance(mockRegistrar.messenger(), mockRegistrar.context(), mockGoogleSignIn); - plugin.setUpRegistrar(mockRegistrar); - } - - @Test - public void requestScopes_ResultErrorIfAccountIsNull() { - MethodCall methodCall = new MethodCall("requestScopes", null); - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(null); - plugin.onMethodCall(methodCall, result); - verify(result).error("sign_in_required", "No account to grant scopes.", null); - } - - @Test - public void requestScopes_ResultTrueIfAlreadyGranted() { - HashMap> arguments = new HashMap<>(); - arguments.put("scopes", Collections.singletonList("requestedScope")); - - MethodCall methodCall = new MethodCall("requestScopes", arguments); - Scope requestedScope = new Scope("requestedScope"); - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(true); - - plugin.onMethodCall(methodCall, result); - verify(result).success(true); - } - - @Test - public void requestScopes_RequestsPermissionIfNotGranted() { - HashMap> arguments = new HashMap<>(); - arguments.put("scopes", Collections.singletonList("requestedScope")); - MethodCall methodCall = new MethodCall("requestScopes", arguments); - Scope requestedScope = new Scope("requestedScope"); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.onMethodCall(methodCall, result); - - verify(mockGoogleSignIn) - .requestPermissions(mockActivity, 53295, account, new Scope[] {requestedScope}); - } - - @Test - public void requestScopes_ReturnsFalseIfPermissionDenied() { - HashMap> arguments = new HashMap<>(); - arguments.put("scopes", Collections.singletonList("requestedScope")); - MethodCall methodCall = new MethodCall("requestScopes", arguments); - Scope requestedScope = new Scope("requestedScope"); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(ActivityResultListener.class); - verify(mockRegistrar).addActivityResultListener(captor.capture()); - ActivityResultListener listener = captor.getValue(); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.onMethodCall(methodCall, result); - listener.onActivityResult( - Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_CANCELED, new Intent()); - - verify(result).success(false); - } - - @Test - public void requestScopes_ReturnsTrueIfPermissionGranted() { - HashMap> arguments = new HashMap<>(); - arguments.put("scopes", Collections.singletonList("requestedScope")); - MethodCall methodCall = new MethodCall("requestScopes", arguments); - Scope requestedScope = new Scope("requestedScope"); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(ActivityResultListener.class); - verify(mockRegistrar).addActivityResultListener(captor.capture()); - ActivityResultListener listener = captor.getValue(); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.onMethodCall(methodCall, result); - listener.onActivityResult( - Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - - verify(result).success(true); - } - - @Test - public void requestScopes_mayBeCalledRepeatedly_ifAlreadyGranted() { - HashMap> arguments = new HashMap<>(); - arguments.put("scopes", Collections.singletonList("requestedScope")); - MethodCall methodCall = new MethodCall("requestScopes", arguments); - Scope requestedScope = new Scope("requestedScope"); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(ActivityResultListener.class); - verify(mockRegistrar).addActivityResultListener(captor.capture()); - ActivityResultListener listener = captor.getValue(); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.onMethodCall(methodCall, result); - listener.onActivityResult( - Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - plugin.onMethodCall(methodCall, result); - listener.onActivityResult( - Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - - verify(result, times(2)).success(true); - } - - @Test - public void requestScopes_mayBeCalledRepeatedly_ifNotSignedIn() { - HashMap> arguments = new HashMap<>(); - arguments.put("scopes", Collections.singletonList("requestedScope")); - MethodCall methodCall = new MethodCall("requestScopes", arguments); - Scope requestedScope = new Scope("requestedScope"); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(ActivityResultListener.class); - verify(mockRegistrar).addActivityResultListener(captor.capture()); - ActivityResultListener listener = captor.getValue(); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(null); - - plugin.onMethodCall(methodCall, result); - listener.onActivityResult( - Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - plugin.onMethodCall(methodCall, result); - listener.onActivityResult( - Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - - verify(result, times(2)).error("sign_in_required", "No account to grant scopes.", null); - } -} diff --git a/packages/image_picker/image_picker/android/build.gradle b/packages/image_picker/image_picker/android/build.gradle index e0d51d8dd1f5..607b3c1523a1 100755 --- a/packages/image_picker/image_picker/android/build.gradle +++ b/packages/image_picker/image_picker/android/build.gradle @@ -35,8 +35,17 @@ android { implementation 'androidx.core:core:1.0.2' implementation 'androidx.annotation:annotation:1.0.0' implementation 'androidx.exifinterface:exifinterface:1.3.0' + + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-core:3.10.0' + testImplementation 'androidx.test:core:1.2.0' + testImplementation "org.robolectric:robolectric:4.3.1" } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } testOptions { unitTests.includeAndroidResources = true diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java rename to packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerCacheTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerCacheTest.java similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerCacheTest.java rename to packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerCacheTest.java diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java rename to packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java rename to packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java diff --git a/packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImageResizerTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImageResizerTest.java similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/java/io/flutter/plugins/imagepicker/ImageResizerTest.java rename to packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImageResizerTest.java diff --git a/packages/image_picker/image_picker/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/packages/image_picker/image_picker/android/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker rename to packages/image_picker/image_picker/android/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/packages/image_picker/image_picker/example/android/app/src/test/resources/pngImage.png b/packages/image_picker/image_picker/android/src/test/resources/pngImage.png similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/test/resources/pngImage.png rename to packages/image_picker/image_picker/android/src/test/resources/pngImage.png From 31c598c57571435b4c6c9e6eacd93e6c577d80c8 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 26 Jul 2021 15:04:18 -0700 Subject: [PATCH 157/364] [flutter_plugin_tools] Test and comment Dart analysis (#4194) Adds a unit test and comments intended to avoid accidental breakage of the Dart repo's run of analysis against this repository. Addresses https://github.com/flutter/plugins/pull/4183#issuecomment-885767597 --- .cirrus.yml | 2 ++ script/configs/custom_analysis.yaml | 5 +++ script/tool/test/analyze_command_test.dart | 39 ++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/.cirrus.yml b/.cirrus.yml index 54c4c3799ec3..5e8425fc2437 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -72,6 +72,8 @@ task: - cd script/tool - dart analyze --fatal-infos script: + # DO NOT change the custom-analysis argument here without changing the Dart repo. + # See the comment in script/configs/custom_analysis.yaml for details. - ./script/tool_runner.sh analyze --custom-analysis=script/configs/custom_analysis.yaml ### Android tasks ### - name: build_all_plugins_apk diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml index f6dc8e288b55..2b0f844de7e0 100644 --- a/script/configs/custom_analysis.yaml +++ b/script/configs/custom_analysis.yaml @@ -8,6 +8,11 @@ # from a top-level package into more specific packages in order to incrementally # migrate a federated plugin. # +# DO NOT move or delete this file without updating +# https://github.com/dart-lang/sdk/blob/master/tools/bots/flutter/analyze_flutter_plugins.sh +# which references this file from source, but out-of-repo. +# Contact stuartmorgan or devoncarew for assistance if necessary. + # TODO(ecosystem): Remove everything from this list. See: # https://github.com/flutter/flutter/issues/76229 - camera diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 9dc8b6a3fca5..da2f0aba86c8 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -251,4 +251,43 @@ void main() { ]), ); }); + + // Ensure that the command used to analyze flutter/plugins in the Dart repo: + // https://github.com/dart-lang/sdk/blob/master/tools/bots/flutter/analyze_flutter_plugins.sh + // continues to work. + // + // DO NOT remove or modify this test without a coordination plan in place to + // modify the script above, as it is run from source, but out-of-repo. + // Contact stuartmorgan or devoncarew for assistance. + test('Dart repo analyze command works', () async { + final Directory pluginDir = createFakePlugin('foo', packagesDir, + extraFiles: ['analysis_options.yaml']); + final File allowFile = packagesDir.childFile('custom.yaml'); + allowFile.writeAsStringSync('- foo'); + + await runCapturingPrint(runner, [ + // DO NOT change this call; see comment above. + 'analyze', + '--analysis-sdk', + 'foo/bar/baz', + '--custom-analysis', + allowFile.path + ]); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'flutter', + const ['packages', 'get'], + pluginDir.path, + ), + ProcessCall( + 'foo/bar/baz/bin/dart', + const ['analyze', '--fatal-infos'], + pluginDir.path, + ), + ]), + ); + }); } From 530a18705f74c4845a45ca20ea0b6d71a36d8e7d Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 27 Jul 2021 01:18:06 +0200 Subject: [PATCH 158/364] [camera_web] Add `buildPreview` implementation (#4190) --- .../example/integration_test/camera_test.dart | 18 ++++++++++++++++ .../integration_test/camera_web_test.dart | 21 ++++++++++++++++--- .../camera/camera_web/lib/src/camera.dart | 3 +++ .../camera/camera_web/lib/src/camera_web.dart | 4 +++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 6eeed23ecf56..b92e6e34cc59 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -507,6 +507,24 @@ void main() { }); }); + group('getViewType', () { + testWidgets('returns a correct view type', (tester) async { + const textureId = 1; + + final camera = Camera( + textureId: textureId, + window: window, + ); + + await camera.initialize(); + + expect( + camera.getViewType(), + equals('plugins.flutter.io/camera_$textureId'), + ); + }); + }); + group('dispose', () { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index d5e1835391ad..7539dd3b33f9 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -11,6 +11,7 @@ import 'package:camera_web/camera_web.dart'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; +import 'package:flutter/widgets.dart' as widgets; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -634,10 +635,24 @@ void main() { ); }); - testWidgets('buildPreview throws UnimplementedError', (tester) async { + testWidgets( + 'buildPreview returns an HtmlElementView ' + 'with an appropriate view type', (tester) async { + final camera = Camera( + textureId: cameraId, + window: window, + ); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + expect( - () => CameraPlatform.instance.buildPreview(cameraId), - throwsUnimplementedError, + CameraPlatform.instance.buildPreview(cameraId), + isA().having( + (view) => view.viewType, + 'viewType', + camera.getViewType(), + ), ); }); diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 334f117be274..06551705f056 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -196,6 +196,9 @@ class Camera { } } + /// Returns the registered view type of the camera. + String getViewType() => _getViewType(textureId); + /// Disposes the camera by stopping the camera stream /// and reloading the camera source. void dispose() { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index e58572e50ee4..263e0539f931 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -361,7 +361,9 @@ class CameraPlugin extends CameraPlatform { @override Widget buildPreview(int cameraId) { - throw UnimplementedError('buildPreview() is not implemented.'); + return HtmlElementView( + viewType: getCamera(cameraId).getViewType(), + ); } @override From 5f42c6920cc6ed3ffe70245e1e840b1969e99be1 Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Mon, 26 Jul 2021 22:41:14 -0700 Subject: [PATCH 159/364] Skip an integration test and extend firebase testlab timeout (#4195) --- .../integration_test/webview_flutter_test.dart | 3 ++- script/tool/lib/src/firebase_test_lab_command.dart | 2 +- script/tool/test/firebase_test_lab_command_test.dart | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index 6005cb0a8ba6..876f961a353b 100644 --- a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -103,6 +103,7 @@ void main() { expect(content.contains('flutter_test_header'), isTrue); }, skip: Platform.isAndroid); + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('JavaScriptChannel', (WidgetTester tester) async { final Completer controllerCompleter = Completer(); @@ -149,7 +150,7 @@ void main() { // https://github.com/flutter/flutter/issues/66318 await controller.evaluateJavascript('Echo.postMessage("hello");1;'); expect(messagesReceived, equals(['hello'])); - }); + }, skip: Platform.isAndroid); testWidgets('resize webview', (WidgetTester tester) async { final String resizeTest = ''' diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 304912824960..8459f6c70153 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -178,7 +178,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { '--test', 'build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk', '--timeout', - '5m', + '7m', '--results-bucket=${getStringArg('results-bucket')}', '--results-dir=$resultsDir', ]; diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index 185b9d83f0fe..35697af3f5fd 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -142,7 +142,7 @@ void main() { '/packages/plugin1/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin1/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin1/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin1/example'), ProcessCall( @@ -156,7 +156,7 @@ void main() { '/packages/plugin2/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin2/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin2/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin2/example'), ]), @@ -219,7 +219,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -229,7 +229,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ]), @@ -445,7 +445,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ]), @@ -601,7 +601,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29' .split(' '), '/packages/plugin/example'), ]), From 2727efc5ba3afcd4f21de2b06a2cbf9955dd41ec Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Tue, 27 Jul 2021 12:46:51 -0700 Subject: [PATCH 160/364] wake lock permission (#4199) --- .../example/android/app/src/main/AndroidManifest.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml index e50fcfd9b330..b8c8d38d45a5 100644 --- a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml @@ -34,4 +34,9 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + From 5516791ec721bc7052a666fa95cafeab19355dfd Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Tue, 27 Jul 2021 13:34:07 -0700 Subject: [PATCH 161/364] [shared_preferences_web] Migrate tests to integration_test (#4185) --- .../shared_preferences_web/CHANGELOG.md | 4 +++ .../shared_preferences_web/example/README.md | 9 +++++++ .../shared_preferences_web_test.dart | 14 ++++++----- .../example/lib/main.dart | 25 +++++++++++++++++++ .../example/pubspec.yaml | 21 ++++++++++++++++ .../example/run_test.sh | 22 ++++++++++++++++ .../example/test_driver/integration_test.dart | 7 ++++++ .../example/web/index.html | 13 ++++++++++ .../shared_preferences_web/test/README.md | 5 ++++ .../test/tests_exist_elsewhere_test.dart | 14 +++++++++++ script/configs/exclude_integration_web.yaml | 2 -- 11 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 packages/shared_preferences/shared_preferences_web/example/README.md rename packages/shared_preferences/shared_preferences_web/{test => example/integration_test}/shared_preferences_web_test.dart (88%) create mode 100644 packages/shared_preferences/shared_preferences_web/example/lib/main.dart create mode 100644 packages/shared_preferences/shared_preferences_web/example/pubspec.yaml create mode 100755 packages/shared_preferences/shared_preferences_web/example/run_test.sh create mode 100644 packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart create mode 100644 packages/shared_preferences/shared_preferences_web/example/web/index.html create mode 100644 packages/shared_preferences/shared_preferences_web/test/README.md create mode 100644 packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart diff --git a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md index ec08267fe59f..ad5d8f0830fa 100644 --- a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Move tests to `example` directory, so they run as integration_tests with `flutter drive`. + ## 2.0.0 * Migrate to null-safety. diff --git a/packages/shared_preferences/shared_preferences_web/example/README.md b/packages/shared_preferences/shared_preferences_web/example/README.md new file mode 100644 index 000000000000..4348451b14e2 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/README.md @@ -0,0 +1,9 @@ +# Testing + +This package uses `package:integration_test` to run its tests in a web browser. + +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. + +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. diff --git a/packages/shared_preferences/shared_preferences_web/test/shared_preferences_web_test.dart b/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart similarity index 88% rename from packages/shared_preferences/shared_preferences_web/test/shared_preferences_web_test.dart rename to packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart index 6e49fb47f755..d95a0512615e 100644 --- a/packages/shared_preferences/shared_preferences_web/test/shared_preferences_web_test.dart +++ b/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart @@ -2,11 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -@TestOn('chrome') import 'dart:convert' show json; import 'dart:html' as html; import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; import 'package:shared_preferences_platform_interface/method_channel_shared_preferences.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:shared_preferences_web/shared_preferences_web.dart'; @@ -20,12 +20,14 @@ const Map kTestValues = { }; void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('SharedPreferencesPlugin', () { setUp(() { html.window.localStorage.clear(); }); - test('registers itself', () { + testWidgets('registers itself', (WidgetTester tester) async { SharedPreferencesStorePlatform.instance = MethodChannelSharedPreferencesStore(); expect(SharedPreferencesStorePlatform.instance, @@ -35,7 +37,7 @@ void main() { isA()); }); - test('getAll', () async { + testWidgets('getAll', (WidgetTester tester) async { final SharedPreferencesPlugin store = SharedPreferencesPlugin(); expect(await store.getAll(), isEmpty); @@ -46,7 +48,7 @@ void main() { expect(allData['flutter.testKey'], 'test value'); }); - test('remove', () async { + testWidgets('remove', (WidgetTester tester) async { final SharedPreferencesPlugin store = SharedPreferencesPlugin(); html.window.localStorage['flutter.testKey'] = '"test value"'; expect(html.window.localStorage['flutter.testKey'], isNotNull); @@ -58,7 +60,7 @@ void main() { ); }); - test('setValue', () async { + testWidgets('setValue', (WidgetTester tester) async { final SharedPreferencesPlugin store = SharedPreferencesPlugin(); for (String key in kTestValues.keys) { final dynamic value = kTestValues[key]; @@ -79,7 +81,7 @@ void main() { ); }); - test('clear', () async { + testWidgets('clear', (WidgetTester tester) async { final SharedPreferencesPlugin store = SharedPreferencesPlugin(); html.window.localStorage['flutter.testKey1'] = '"test value"'; html.window.localStorage['flutter.testKey2'] = '42'; diff --git a/packages/shared_preferences/shared_preferences_web/example/lib/main.dart b/packages/shared_preferences/shared_preferences_web/example/lib/main.dart new file mode 100644 index 000000000000..e1a38dcdcd46 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/lib/main.dart @@ -0,0 +1,25 @@ +// Copyright 2013 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 'package:flutter/material.dart'; + +void main() { + runApp(MyApp()); +} + +/// App for testing +class MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Text('Testing... Look at the console output for results!'), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml b/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml new file mode 100644 index 000000000000..a83a71b40bf8 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: shared_preferences_web_integration_tests +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.2.0" + +dependencies: + shared_preferences_web: + path: ../ + flutter: + sdk: flutter + +dev_dependencies: + js: ^0.6.3 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter diff --git a/packages/shared_preferences/shared_preferences_web/example/run_test.sh b/packages/shared_preferences/shared_preferences_web/example/run_test.sh new file mode 100755 index 000000000000..aa52974f310e --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/run_test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/bash +# Copyright 2013 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. + +if pgrep -lf chromedriver > /dev/null; then + echo "chromedriver is running." + + if [ $# -eq 0 ]; then + echo "No target specified, running all tests..." + find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + else + echo "Running test target: $1..." + set -x + flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target=$1 + fi + + else + echo "chromedriver is not running." + echo "Please, check the README.md for instructions on how to use run_test.sh" +fi + diff --git a/packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart b/packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/shared_preferences/shared_preferences_web/example/web/index.html b/packages/shared_preferences/shared_preferences_web/example/web/index.html new file mode 100644 index 000000000000..7fb138cc90fa --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/web/index.html @@ -0,0 +1,13 @@ + + + + + + example + + + + + diff --git a/packages/shared_preferences/shared_preferences_web/test/README.md b/packages/shared_preferences/shared_preferences_web/test/README.md new file mode 100644 index 000000000000..7c5b4ad682ba --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/test/README.md @@ -0,0 +1,5 @@ +## test + +This package uses integration tests for testing. + +See `example/README.md` for more info. diff --git a/packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart b/packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart new file mode 100644 index 000000000000..442c50144727 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart @@ -0,0 +1,14 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Tell the user where to find the real tests', () { + print('---'); + print('This package uses integration_test for its tests.'); + print('See `example/README.md` for more info.'); + print('---'); + }); +} diff --git a/script/configs/exclude_integration_web.yaml b/script/configs/exclude_integration_web.yaml index 99e20831b3c2..6c0fc4efcb7a 100644 --- a/script/configs/exclude_integration_web.yaml +++ b/script/configs/exclude_integration_web.yaml @@ -1,4 +1,2 @@ -# Currently missing: https://github.com/flutter/flutter/issues/81982 -- shared_preferences_web # Currently missing: https://github.com/flutter/flutter/issues/82211 - file_selector From 9e23302ad72f9802776990369a7ee43d3398c60c Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 27 Jul 2021 23:34:08 +0200 Subject: [PATCH 162/364] [camera_web] Add `takePicture` implementation (#4196) --- .../integration_test/camera_web_test.dart | 38 ++++++++++++++++--- .../integration_test/helpers/mocks.dart | 3 ++ .../camera/camera_web/lib/src/camera_web.dart | 2 +- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 7539dd3b33f9..0b35fcf64234 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -481,11 +481,39 @@ void main() { ); }); - testWidgets('takePicture throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.takePicture(cameraId), - throwsUnimplementedError, - ); + group('takePicture', () { + testWidgets( + 'throws CameraException ' + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets('captures a picture', (tester) async { + final camera = MockCamera(); + final capturedPicture = MockXFile(); + + when(camera.takePicture) + .thenAnswer((_) => Future.value(capturedPicture)); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final picture = await CameraPlatform.instance.takePicture(cameraId); + + verify(camera.takePicture).called(1); + + expect(picture, equals(capturedPicture)); + }); }); testWidgets('prepareForVideoRecording throws UnimplementedError', diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index fa627ca0b7e6..54a4f594fe07 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -7,6 +7,7 @@ import 'dart:ui'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:mocktail/mocktail.dart'; class MockWindow extends Mock implements Window {} @@ -21,6 +22,8 @@ class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} class MockCamera extends Mock implements Camera {} +class MockXFile extends Mock implements XFile {} + /// A fake [MediaStream] that returns the provided [_videoTracks]. class FakeMediaStream extends Fake implements MediaStream { FakeMediaStream(this._videoTracks); diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 263e0539f931..7a5738db3622 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -271,7 +271,7 @@ class CameraPlugin extends CameraPlatform { @override Future takePicture(int cameraId) { - throw UnimplementedError('takePicture() is not implemented.'); + return getCamera(cameraId).takePicture(); } @override From d2f5c33e94291cd21b9999fb02f4e3efb1f618b6 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 27 Jul 2021 14:39:09 -0700 Subject: [PATCH 163/364] Remove pubspec.yaml examples from READMEs (#4198) --- packages/e2e/README.md | 192 ------------------ .../file_selector_web/CHANGELOG.md | 4 + .../file_selector/file_selector_web/README.md | 27 +-- .../file_selector_web/pubspec.yaml | 2 +- .../google_sign_in_web/CHANGELOG.md | 4 + .../google_sign_in_web/README.md | 17 +- .../google_sign_in_web/pubspec.yaml | 2 +- .../image_picker_for_web/CHANGELOG.md | 4 + .../image_picker_for_web/README.md | 18 +- .../image_picker_for_web/pubspec.yaml | 2 +- .../in_app_purchase_android/CHANGELOG.md | 4 + .../in_app_purchase_android/README.md | 34 +--- .../in_app_purchase_android/pubspec.yaml | 2 +- .../in_app_purchase_ios/CHANGELOG.md | 4 + .../in_app_purchase_ios/README.md | 34 +--- .../in_app_purchase_ios/pubspec.yaml | 2 +- .../path_provider_linux/CHANGELOG.md | 4 + .../path_provider_linux/README.md | 9 +- .../path_provider_linux/pubspec.yaml | 2 +- .../path_provider_macos/CHANGELOG.md | 3 +- .../path_provider_macos/README.md | 29 +-- .../path_provider_macos/pubspec.yaml | 2 +- .../path_provider_windows/CHANGELOG.md | 4 + .../path_provider_windows/README.md | 22 +- .../path_provider_windows/pubspec.yaml | 2 +- .../shared_preferences_linux/CHANGELOG.md | 4 + .../shared_preferences_linux/README.md | 21 +- .../shared_preferences_linux/pubspec.yaml | 2 +- .../shared_preferences_macos/CHANGELOG.md | 3 +- .../shared_preferences_macos/README.md | 33 +-- .../shared_preferences_macos/pubspec.yaml | 2 +- .../shared_preferences_web/CHANGELOG.md | 3 +- .../shared_preferences_web/README.md | 31 +-- .../shared_preferences_web/pubspec.yaml | 2 +- .../shared_preferences_windows/CHANGELOG.md | 4 + .../shared_preferences_windows/README.md | 22 +- .../shared_preferences_windows/pubspec.yaml | 2 +- .../url_launcher_linux/CHANGELOG.md | 4 + .../url_launcher/url_launcher_linux/README.md | 33 +-- .../url_launcher_linux/pubspec.yaml | 2 +- .../url_launcher_macos/CHANGELOG.md | 3 +- .../url_launcher/url_launcher_macos/README.md | 33 +-- .../url_launcher_macos/pubspec.yaml | 2 +- .../url_launcher_web/CHANGELOG.md | 4 + .../url_launcher/url_launcher_web/README.md | 36 +--- .../url_launcher_web/pubspec.yaml | 2 +- .../url_launcher_windows/CHANGELOG.md | 4 + .../url_launcher_windows/README.md | 33 +-- .../url_launcher_windows/pubspec.yaml | 2 +- .../video_player_web/CHANGELOG.md | 4 + .../video_player/video_player_web/README.md | 18 +- .../video_player_web/pubspec.yaml | 2 +- 52 files changed, 164 insertions(+), 576 deletions(-) diff --git a/packages/e2e/README.md b/packages/e2e/README.md index 7f211900db70..e86126e4cc56 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -1,195 +1,3 @@ # e2e (deprecated) -## DEPRECATED - This package has been moved to [integration_test](https://github.com/flutter/plugins/tree/master/packages/integration_test). - -## Old instructions - -This package enables self-driving testing of Flutter code on devices and emulators. -It adapts flutter_test results into a format that is compatible with `flutter drive` -and native Android instrumentation testing. - -## Usage - -Add a dependency on the `e2e` package in the -`dev_dependencies` section of pubspec.yaml. For plugins, do this in the -pubspec.yaml of the example app. - -Invoke `E2EWidgetsFlutterBinding.ensureInitialized()` at the start -of a test file, e.g. - -```dart -import 'package:e2e/e2e.dart'; - -void main() { - E2EWidgetsFlutterBinding.ensureInitialized(); - testWidgets("failing test example", (WidgetTester tester) async { - expect(2 + 2, equals(5)); - }); -} -``` - -## Test locations - -It is recommended to put e2e tests in the `test/` folder of the app or package. -For example apps, if the e2e test references example app code, it should go in -`example/test/`. It is also acceptable to put e2e tests in `test_driver/` folder -so that they're alongside the runner app (see below). - -## Using Flutter driver to run tests - -`E2EWidgetsTestBinding` supports launching the on-device tests with `flutter drive`. -Note that the tests don't use the `FlutterDriver` API, they use `testWidgets` instead. - -Put the a file named `_e2e_test.dart` in the app' `test_driver` directory: - -```dart -import 'dart:async'; - -import 'package:e2e/e2e_driver.dart' as e2e; - -Future main() async => e2e.main(); - -``` - -To run a example app test with Flutter driver: - -``` -cd example -flutter drive test/_e2e.dart -``` - -To test plugin APIs using Flutter driver: - -``` -cd example -flutter drive --driver=test_driver/_test.dart test/_e2e.dart -``` - -You can run tests on web in release or profile mode. - -First you need to make sure you have downloaded the driver for the browser. - -``` -cd example -flutter drive -v --target=test_driver/dart -d web-server --release --browser-name=chrome -``` - -## Android device testing - -Create an instrumentation test file in your application's -**android/app/src/androidTest/java/com/example/myapp/** directory (replacing -com, example, and myapp with values from your app's package name). You can name -this test file MainActivityTest.java or another name of your choice. - -```java -package com.example.myapp; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.e2e.FlutterTestRunner; -import org.junit.Rule; -import org.junit.runner.RunWith; - -@RunWith(FlutterTestRunner.class) -public class MainActivityTest { - @Rule - public ActivityTestRule rule = new ActivityTestRule<>(MainActivity.class, true, false); -} -``` - -Update your application's **myapp/android/app/build.gradle** to make sure it -uses androidx's version of AndroidJUnitRunner and has androidx libraries as a -dependency. - -``` -android { - ... - defaultConfig { - ... - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } -} - -dependencies { - testImplementation 'junit:junit:4.12' - - // https://developer.android.com/jetpack/androidx/releases/test/#1.2.0 - androidTestImplementation 'androidx.test:runner:1.2.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' -} -``` - -To e2e test on a local Android device (emulated or physical): - -``` -./gradlew app:connectedAndroidTest -Ptarget=`pwd`/../test_driver/_e2e.dart -``` - -## Firebase Test Lab - -If this is your first time testing with Firebase Test Lab, you'll need to follow -the guides in the [Firebase test lab -documentation](https://firebase.google.com/docs/test-lab/?gclid=EAIaIQobChMIs5qVwqW25QIV8iCtBh3DrwyUEAAYASAAEgLFU_D_BwE) -to set up a project. - -To run an e2e test on Android devices using Firebase Test Lab, use gradle commands to build an -instrumentation test for Android, after creating `androidTest` as suggested in the last section. - -```bash -pushd android -# flutter build generates files in android/ for building the app -flutter build apk -./gradlew app:assembleAndroidTest -./gradlew app:assembleDebug -Ptarget=.dart -popd -``` - -Upload the build apks Firebase Test Lab, making sure to replace , -, , and with your values. - -```bash -gcloud auth activate-service-account --key-file= -gcloud --quiet config set project -gcloud firebase test android run --type instrumentation \ - --app build/app/outputs/apk/debug/app-debug.apk \ - --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk\ - --timeout 2m \ - --results-bucket= \ - --results-dir= -``` - -You can pass additional parameters on the command line, such as the -devices you want to test on. See -[gcloud firebase test android run](https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run). - -## iOS device testing - -You need to change `iOS/Podfile` to avoid test target statically linking to the plugins. One way is to -link all of the plugins dynamically: - -``` -target 'Runner' do - use_frameworks! - ... -end -``` - -To e2e test on your iOS device (simulator or real), rebuild your iOS targets with Flutter tool. - -``` -flutter build ios -t test_driver/_e2e.dart (--simulator) -``` - -Open Xcode project (by default, it's `ios/Runner.xcodeproj`). Create a test target -(navigating `File > New > Target...` and set up the values) and a test file `RunnerTests.m` and -change the code. You can change `RunnerTests.m` to the name of your choice. - -```objective-c -#import -#import - -E2E_IOS_RUNNER(RunnerTests) -``` - -Now you can start RunnerTests to kick out e2e tests! diff --git a/packages/file_selector/file_selector_web/CHANGELOG.md b/packages/file_selector/file_selector_web/CHANGELOG.md index 3eb7c3b94494..dadf5ffdc3fc 100644 --- a/packages/file_selector/file_selector_web/CHANGELOG.md +++ b/packages/file_selector/file_selector_web/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.8.1+1 + +- Updated installation instructions in README. + # 0.8.1 - Return a non-null value from `getSavePath` for consistency with diff --git a/packages/file_selector/file_selector_web/README.md b/packages/file_selector/file_selector_web/README.md index 24d48f48586f..026e5859e6f3 100644 --- a/packages/file_selector/file_selector_web/README.md +++ b/packages/file_selector/file_selector_web/README.md @@ -1,30 +1,11 @@ -# file_selector_web +# file\_selector\_web The web implementation of [`file_selector`][1]. ## Usage -### Import the package -To use this plugin in your Flutter Web app, simply add it as a dependency in -your pubspec alongside the base `file_selector` plugin. - -_(This is only temporary: in the future we hope to make this package an -"endorsed" implementation of `file_selector`, so that it is automatically -included in your Flutter Web app when you depend on `package:file_selector`.)_ - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - file_selector: ^0.7.0 - file_selector_web: ^0.7.0 - ... -``` - -### Use the plugin -Once you have the `file_selector_web` dependency in your pubspec, you should -be able to use `package:file_selector` as normal. +This package is [endorsed][2], which means you can simply use `file_selector` +normally. This package will be automatically included in your app when you do. [1]: https://pub.dev/packages/file_selector +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/file_selector/file_selector_web/pubspec.yaml b/packages/file_selector/file_selector_web/pubspec.yaml index ebbdfdbbd4da..9753f9216694 100644 --- a/packages/file_selector/file_selector_web/pubspec.yaml +++ b/packages/file_selector/file_selector_web/pubspec.yaml @@ -2,7 +2,7 @@ name: file_selector_web description: Web platform implementation of file_selector repository: https://github.com/flutter/plugins/tree/master/packages/file_selector/file_selector_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+file_selector%22 -version: 0.8.1 +version: 0.8.1+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md index a5c9e9d2f2bb..8a2f1dbf56d2 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.0+1 + +* Updated installation instructions in README. + ## 0.10.0 * Migrate to null-safety. diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md index faf04de024af..501ea14eebe6 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -1,4 +1,4 @@ -# google_sign_in_web +# google\_sign\_in\_web The web implementation of [google_sign_in](https://pub.dev/google_sign_in/google_sign_in) @@ -6,18 +6,9 @@ The web implementation of [google_sign_in](https://pub.dev/google_sign_in/google ### Import the package -This package is the endorsed implementation of `google_sign_in` for the web platform since version `4.1.0`, so it gets automatically added to your dependencies by depending on `google_sign_in: ^4.1.0`. - -No modifications to your pubspec.yaml should be required in a recent enough version of Flutter (`>=1.12.13+hotfix.4`): - -```yaml -... -dependencies: - ... - google_sign_in: ^4.1.0 - ... -... -``` +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `google_sign_in` +normally. This package will be automatically included in your app when you do. ### Web integration diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index 44020fe598c3..0de229e795ce 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.10.0 +version: 0.10.0+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/image_picker/image_picker_for_web/CHANGELOG.md b/packages/image_picker/image_picker_for_web/CHANGELOG.md index f32a5d8e92cd..01d13f900d2d 100644 --- a/packages/image_picker/image_picker_for_web/CHANGELOG.md +++ b/packages/image_picker/image_picker_for_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.2 + +* Updated installation instructions in README. + # 2.1.1 * Implemented `getMultiImage`. diff --git a/packages/image_picker/image_picker_for_web/README.md b/packages/image_picker/image_picker_for_web/README.md index 8c9f2c73b8fe..73f2dfc4b84f 100644 --- a/packages/image_picker/image_picker_for_web/README.md +++ b/packages/image_picker/image_picker_for_web/README.md @@ -1,4 +1,4 @@ -# image_picker_for_web +# image\_picker\_for\_web A web implementation of [`image_picker`][1]. @@ -52,19 +52,9 @@ The argument `maxDuration` is not supported on the web. ### Import the package -This package is an unendorsed web platform implementation of `image_picker`. - -In order to use this, you'll need to depend in `image_picker: ^0.6.7` (which was the first version of the plugin that allowed federation), and `image_picker_for_web: ^0.1.0`. - -```yaml -... -dependencies: - ... - image_picker: ^0.6.7 - image_picker_for_web: ^0.1.0 - ... -... -``` +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `image_picker` +normally. This package will be automatically included in your app when you do. ### Use the plugin diff --git a/packages/image_picker/image_picker_for_web/pubspec.yaml b/packages/image_picker/image_picker_for_web/pubspec.yaml index b2479285a3ea..6296992c46d0 100644 --- a/packages/image_picker/image_picker_for_web/pubspec.yaml +++ b/packages/image_picker/image_picker_for_web/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_for_web description: Web platform implementation of image_picker repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker_for_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 2.1.1 +version: 2.1.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 824b432d5021..32f9aa60e4ca 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.4+3 + +- Updated installation instructions in README. + ## 0.1.4+2 * Added price currency symbol to SkuDetailsWrapper. diff --git a/packages/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md index 684dd66d48a2..a2f252f8d3ef 100644 --- a/packages/in_app_purchase/in_app_purchase_android/README.md +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -1,35 +1,15 @@ -# in_app_purchase_android +# in\_app\_purchase\_android The Android implementation of [`in_app_purchase`][1]. ## Usage -### Import the package - -This package has been endorsed, meaning that you only need to add `in_app_purchase` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:in_app_purchase`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - in_app_purchase: ^0.6.0 - ... +This package has been [endorsed][2], meaning that you only need to add `in_app_purchase` +as a dependency in your `pubspec.yaml`. This package will be automatically included in your app +when you do. ``` -If you wish to use the Android package only, you can add `in_app_purchase_android` as a -dependency: - -```yaml -... -dependencies: - ... - in_app_purchase_android: ^1.0.0 - ... -``` +If you wish to use the Android package only, you can [add `in_app_purchase_android` directly][3]. ## Contributing @@ -45,4 +25,6 @@ If you would like to contribute to the plugin, check out our [contribution guide](https://github.com/flutter/plugins/blob/master/CONTRIBUTING.md). -[1]: ../in_app_purchase/in_app_purchase \ No newline at end of file +[1]: ../in_app_purchase/in_app_purchase +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin +[3]: https://pub.dev/packages/in_app_purchase_android/install diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 41136e7501f6..f8e63821657a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+2 +version: 0.1.4+3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 4a2ace891562..305d5a13647c 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.3+1 + +- Updated installation instructions in README. + ## 0.1.3 * Add price symbol to platform interface object ProductDetail. diff --git a/packages/in_app_purchase/in_app_purchase_ios/README.md b/packages/in_app_purchase/in_app_purchase_ios/README.md index 46839b5ee3ec..ec72889a8ee2 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/README.md +++ b/packages/in_app_purchase/in_app_purchase_ios/README.md @@ -1,35 +1,15 @@ -# in_app_purchase_ios +# in\_app\_purchase\_ios The iOS implementation of [`in_app_purchase`][1]. ## Usage -### Import the package - -This package has been endorsed, meaning that you only need to add `in_app_purchase` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:in_app_purchase`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - in_app_purchase: ^0.6.0 - ... +This package has been [endorsed][2], meaning that you only need to add `in_app_purchase` +as a dependency in your `pubspec.yaml`. This package will be automatically included in your app +when you do. ``` -If you wish to use the iOS package only, you can add `in_app_purchase_ios` as a -dependency: - -```yaml -... -dependencies: - ... - in_app_purchase_ios: ^1.0.0 - ... -``` +If you wish to use the iOS package only, you can [add `in_app_purchase_ios` directly][3]. ## Contributing @@ -45,4 +25,6 @@ If you would like to contribute to the plugin, check out our [contribution guide](https://github.com/flutter/plugins/blob/master/CONTRIBUTING.md). -[1]: ../in_app_purchase/in_app_purchase \ No newline at end of file +[1]: ../in_app_purchase/in_app_purchase +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin +[3]: https://pub.dev/packages/in_app_purchase_ios/install diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 89b3ad19bacd..5f3b08520eb6 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3 +version: 0.1.3+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/path_provider/path_provider_linux/CHANGELOG.md b/packages/path_provider/path_provider_linux/CHANGELOG.md index 9383181d6a76..66c11a42c3eb 100644 --- a/packages/path_provider/path_provider_linux/CHANGELOG.md +++ b/packages/path_provider/path_provider_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Updated installation instructions in README. + ## 2.0.1 * Add `implements` to pubspec.yaml. diff --git a/packages/path_provider/path_provider_linux/README.md b/packages/path_provider/path_provider_linux/README.md index ef9e0e855c86..b0b73dcb0ecd 100644 --- a/packages/path_provider/path_provider_linux/README.md +++ b/packages/path_provider/path_provider_linux/README.md @@ -1,8 +1,11 @@ -# path_provider_linux +# path\_provider\_linux The linux implementation of [`path_provider`]. ## Usage -This package is already included as part of the `path_provider` package dependency, and will -be included when using `path_provider` as normal. You will need to use version 1.6.10 or newer. +This package is [endorsed][2], which means you can simply use `path_provider` +normally. This package will be automatically included in your app when you do. + +[1]: https://pub.dev/packages/path_provider +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/path_provider/path_provider_linux/pubspec.yaml b/packages/path_provider/path_provider_linux/pubspec.yaml index 7e015dca06db..4d43302ce6b3 100644 --- a/packages/path_provider/path_provider_linux/pubspec.yaml +++ b/packages/path_provider/path_provider_linux/pubspec.yaml @@ -2,7 +2,7 @@ name: path_provider_linux description: Linux implementation of the path_provider plugin repository: https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider_linux issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+path_provider%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/path_provider/path_provider_macos/CHANGELOG.md b/packages/path_provider/path_provider_macos/CHANGELOG.md index d5f9ce860b6f..1d0738c3757a 100644 --- a/packages/path_provider/path_provider_macos/CHANGELOG.md +++ b/packages/path_provider/path_provider_macos/CHANGELOG.md @@ -1,7 +1,8 @@ -## NEXT +# 2.0.2 * Add Swift language version to podspec. * Add native unit tests. +* Updated installation instructions in README. ## 2.0.1 diff --git a/packages/path_provider/path_provider_macos/README.md b/packages/path_provider/path_provider_macos/README.md index 23727fe7f370..00abdf24cd79 100644 --- a/packages/path_provider/path_provider_macos/README.md +++ b/packages/path_provider/path_provider_macos/README.md @@ -1,30 +1,11 @@ -# path_provider_macos +# path\_provider\_macos The macos implementation of [`path_provider`]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `path_provider` +normally. This package will be automatically included in your app when you do. -To use this plugin in your Flutter macos app, simply add it as a dependency in -your `pubspec.yaml` alongside the base `path_provider` plugin. - -_(This is only temporary: in the future we hope to make this package an -"endorsed" implementation of `path_provider`, so that it is automatically -included in your Flutter macos app when you depend on `package:path_provider`.)_ - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - path_provider: ^1.5.1 - path_provider_macos: ^0.0.1 - ... -``` - -### Use the plugin - -Once you have the `path_provider_macos` dependency in your pubspec, you should -be able to use `package:path_provider` as normal. +[1]: https://pub.dev/packages/path_provider +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/path_provider/path_provider_macos/pubspec.yaml b/packages/path_provider/path_provider_macos/pubspec.yaml index 329bffa61c10..140e4cde9d58 100644 --- a/packages/path_provider/path_provider_macos/pubspec.yaml +++ b/packages/path_provider/path_provider_macos/pubspec.yaml @@ -2,7 +2,7 @@ name: path_provider_macos description: macOS implementation of the path_provider plugin repository: https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider_macos issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+path_provider%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/path_provider/path_provider_windows/CHANGELOG.md b/packages/path_provider/path_provider_windows/CHANGELOG.md index 2e4da0e1f353..953bb894de09 100644 --- a/packages/path_provider/path_provider_windows/CHANGELOG.md +++ b/packages/path_provider/path_provider_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.3 + +* Updated installation instructions in README. + ## 2.0.2 * Add `implements` to pubspec.yaml. diff --git a/packages/path_provider/path_provider_windows/README.md b/packages/path_provider/path_provider_windows/README.md index 6d452e770469..31813edf21d1 100644 --- a/packages/path_provider/path_provider_windows/README.md +++ b/packages/path_provider/path_provider_windows/README.md @@ -1,23 +1,11 @@ -# path_provider_windows +# path\_provider\_windows The Windows implementation of [`path_provider`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `path_provider` +normally. This package will be automatically included in your app when you do. -This package has been endorsed, meaning that you only need to add `path_provider` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:path_provider`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - path_provider: ^1.6.15 - ... -``` - -[1]:../ +[1]: https://pub.dev/packages/path_provider +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/path_provider/path_provider_windows/pubspec.yaml b/packages/path_provider/path_provider_windows/pubspec.yaml index e00e6d1373f2..0353574b6235 100644 --- a/packages/path_provider/path_provider_windows/pubspec.yaml +++ b/packages/path_provider/path_provider_windows/pubspec.yaml @@ -2,7 +2,7 @@ name: path_provider_windows description: Windows implementation of the path_provider plugin repository: https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider_windows issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+path_provider%22 -version: 2.0.2 +version: 2.0.3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md b/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md index 9a17d2455ad8..fc09bec23591 100644 --- a/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Updated installation instructions in README. + ## 2.0.1 * Add `implements` to the pubspec. diff --git a/packages/shared_preferences/shared_preferences_linux/README.md b/packages/shared_preferences/shared_preferences_linux/README.md index 1894f50ae99e..1a4ef3781b7e 100644 --- a/packages/shared_preferences/shared_preferences_linux/README.md +++ b/packages/shared_preferences/shared_preferences_linux/README.md @@ -1,22 +1,11 @@ -# shared_preferences_linux +# shared\_preferences\_linux The Linux implementation of [`shared_preferences`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -This package is an unendorsed Linux implementation of `shared_preferences`. - -In order to use this now, you'll need to depend on `shared_preferences_linux`. -When this package is endorsed it will be automatically used by the `shared_preferences` package and you can switch to that API. - -```yaml -... -dependencies: - ... - shared_preferences_linux: ^0.0.1 - ... -``` - -[1]: ../ +[1]: https://pub.dev/packages/shared_preferences +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/shared_preferences/shared_preferences_linux/pubspec.yaml b/packages/shared_preferences/shared_preferences_linux/pubspec.yaml index 9bfe24dfa829..c03e49e042e2 100644 --- a/packages/shared_preferences/shared_preferences_linux/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_linux/pubspec.yaml @@ -2,7 +2,7 @@ name: shared_preferences_linux description: Linux implementation of the shared_preferences plugin repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences_linux issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md b/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md index d5ace31073ad..2f7e0edf9a51 100644 --- a/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 2.0.2 * Add native unit tests. +* Updated installation instructions in README. ## 2.0.1 diff --git a/packages/shared_preferences/shared_preferences_macos/README.md b/packages/shared_preferences/shared_preferences_macos/README.md index 170a8270c402..e9cd7f25be03 100644 --- a/packages/shared_preferences/shared_preferences_macos/README.md +++ b/packages/shared_preferences/shared_preferences_macos/README.md @@ -1,34 +1,11 @@ -# shared_preferences_macos +# shared\_preferences\_macos The macos implementation of [`shared_preferences`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -This package has been endorsed, meaning that you only need to add `shared_preferences` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:shared_preferences`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - shared_preferences: ^0.5.6 - ... -``` - -If you wish to use the macos package only, you can add `shared_preferences_macos` as a -dependency: - -```yaml -... -dependencies: - ... - shared_preferences_macos: ^0.0.1 - ... -``` - -[1]: ../ +[1]: https://pub.dev/packages/shared_preferences +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/shared_preferences/shared_preferences_macos/pubspec.yaml b/packages/shared_preferences/shared_preferences_macos/pubspec.yaml index 5eddba2d51ad..6e351e86fb1a 100644 --- a/packages/shared_preferences/shared_preferences_macos/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_macos/pubspec.yaml @@ -2,7 +2,7 @@ name: shared_preferences_macos description: macOS implementation of the shared_preferences plugin. repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences_macos issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md index ad5d8f0830fa..0a00e7d66a2a 100644 --- a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.0.1 +* Updated installation instructions in README. * Move tests to `example` directory, so they run as integration_tests with `flutter drive`. ## 2.0.0 diff --git a/packages/shared_preferences/shared_preferences_web/README.md b/packages/shared_preferences/shared_preferences_web/README.md index 8f9d22d86ef5..5c3a51a3d9dc 100644 --- a/packages/shared_preferences/shared_preferences_web/README.md +++ b/packages/shared_preferences/shared_preferences_web/README.md @@ -1,32 +1,11 @@ -# shared_preferences_web +# shared\_preferences\_web The web implementation of [`shared_preferences`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -To use this plugin in your Flutter Web app, simply add it as a dependency in -your `pubspec.yaml` alongside the base `shared_preferences` plugin. - -_(This is only temporary: in the future we hope to make this package an -"endorsed" implementation of `shared_preferences`, so that it is automatically -included in your Flutter Web app when you depend on `package:shared_preferences`.)_ - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - shared_preferences: ^0.5.4+8 - shared_preferences_web: ^0.1.0 - ... -``` - -### Use the plugin - -Once you have the `shared_preferences_web` dependency in your pubspec, you should -be able to use `package:shared_preferences` as normal. - -[1]: ../shared_preferences/shared_preferences +[1]: https://pub.dev/packages/shared_preferences +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/shared_preferences/shared_preferences_web/pubspec.yaml b/packages/shared_preferences/shared_preferences_web/pubspec.yaml index cd2e063fe6b3..2e67be20e427 100644 --- a/packages/shared_preferences/shared_preferences_web/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_web/pubspec.yaml @@ -2,7 +2,7 @@ name: shared_preferences_web description: Web platform implementation of shared_preferences repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.0 +version: 2.0.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md b/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md index 34c48f37af48..7502ec917d80 100644 --- a/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Updated installation instructions in README. + ## 2.0.1 * Add `implements` to pubspec.yaml. diff --git a/packages/shared_preferences/shared_preferences_windows/README.md b/packages/shared_preferences/shared_preferences_windows/README.md index dd710f4c7336..68341acf505e 100644 --- a/packages/shared_preferences/shared_preferences_windows/README.md +++ b/packages/shared_preferences/shared_preferences_windows/README.md @@ -1,23 +1,11 @@ -# shared_preferences_windows +# shared\_preferences\_windows The Windows implementation of [`shared_preferences`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -This package has been endorsed, meaning that you only need to add `shared_preferences` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:shared_preferences`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - shared_preferences: ^0.5.7 - ... -``` - -[1]: ../ +[1]: https://pub.dev/packages/shared_preferences +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/shared_preferences/shared_preferences_windows/pubspec.yaml b/packages/shared_preferences/shared_preferences_windows/pubspec.yaml index 2cc59d5aa635..87b685f6d0bc 100644 --- a/packages/shared_preferences/shared_preferences_windows/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_windows/pubspec.yaml @@ -2,7 +2,7 @@ name: shared_preferences_windows description: Windows implementation of shared_preferences repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences_windows issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: '>=2.12.0 <3.0.0' diff --git a/packages/url_launcher/url_launcher_linux/CHANGELOG.md b/packages/url_launcher/url_launcher_linux/CHANGELOG.md index ec9fad53437c..b872a55ef161 100644 --- a/packages/url_launcher/url_launcher_linux/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.1 + +* Updated installation instructions in README. + ## 2.0.0 * Migrate to null safety. diff --git a/packages/url_launcher/url_launcher_linux/README.md b/packages/url_launcher/url_launcher_linux/README.md index 0474c58da40e..23c0019b6948 100644 --- a/packages/url_launcher/url_launcher_linux/README.md +++ b/packages/url_launcher/url_launcher_linux/README.md @@ -1,34 +1,11 @@ -# url_launcher_linux +# url\_launcher\_linux The Linux implementation of [`url_launcher`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -This package has been endorsed, meaning that you only need to add `url_launcher` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:url_launcher`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - url_launcher: ^5.5.0 - ... -``` - -If you wish to use the Linux package only, you can add `url_launcher_linux` as a -dependency: - -```yaml -... -dependencies: - ... - url_launcher_linux: ^0.0.1 - ... -``` - -[1]: ../ +[1]: https://pub.dev/packages/url_launcher +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/url_launcher/url_launcher_linux/pubspec.yaml b/packages/url_launcher/url_launcher_linux/pubspec.yaml index a5d6ddd24ff4..e08011e496d5 100644 --- a/packages/url_launcher/url_launcher_linux/pubspec.yaml +++ b/packages/url_launcher/url_launcher_linux/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_linux description: Linux implementation of the url_launcher plugin. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_linux issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.0 +version: 2.0.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_macos/CHANGELOG.md b/packages/url_launcher/url_launcher_macos/CHANGELOG.md index 976f7719329b..2f672940f2ac 100644 --- a/packages/url_launcher/url_launcher_macos/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_macos/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 2.0.1 * Add native unit tests. +* Updated installation instructions in README. ## 2.0.0 diff --git a/packages/url_launcher/url_launcher_macos/README.md b/packages/url_launcher/url_launcher_macos/README.md index 28aa18817d6c..b594cde1d041 100644 --- a/packages/url_launcher/url_launcher_macos/README.md +++ b/packages/url_launcher/url_launcher_macos/README.md @@ -1,34 +1,11 @@ -# url_launcher_macos +# url\_launcher\_macos The macos implementation of [`url_launcher`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -This package has been endorsed, meaning that you only need to add `url_launcher` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:url_launcher`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - url_launcher: ^5.4.1 - ... -``` - -If you wish to use the macos package only, you can add `url_launcher_macos` as a -dependency: - -```yaml -... -dependencies: - ... - url_launcher_macos: ^0.0.1 - ... -``` - -[1]: ../url_launcher/url_launcher +[1]: https://pub.dev/packages/url_launcher +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/url_launcher/url_launcher_macos/pubspec.yaml b/packages/url_launcher/url_launcher_macos/pubspec.yaml index 4a0eac109ab5..2483e35e56de 100644 --- a/packages/url_launcher/url_launcher_macos/pubspec.yaml +++ b/packages/url_launcher/url_launcher_macos/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_macos description: macOS implementation of the url_launcher plugin. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_macos issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.0 +version: 2.0.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index 488c3387cb68..b1fff136793d 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +- Updated installation instructions in README. + # 2.0.1 - Change sizing code of `Link` widget's `HtmlElementView` so it works well when slotted. diff --git a/packages/url_launcher/url_launcher_web/README.md b/packages/url_launcher/url_launcher_web/README.md index 21ab2fc52927..b03d15478ee3 100644 --- a/packages/url_launcher/url_launcher_web/README.md +++ b/packages/url_launcher/url_launcher_web/README.md @@ -1,37 +1,11 @@ -# url_launcher_web +# url\_launcher\_web The web implementation of [`url_launcher`][1]. -**Please set your constraint to `url_launcher_web: '>=0.1.y+x <2.0.0'`** - -## Backward compatible 1.0.0 version is coming -The plugin has reached a stable API, we guarantee that version `1.0.0` will be backward compatible with `0.1.y+z`. -Please use `url_launcher_web: '>=0.1.y+x <2.0.0'` as your dependency constraint to allow a smoother ecosystem migration. -For more details see: https://github.com/flutter/flutter/wiki/Package-migration-to-1.0.0 - ## Usage -### Import the package -To use this plugin in your Flutter Web app, simply add it as a dependency in -your pubspec alongside the base `url_launcher` plugin. - -_(This is only temporary: in the future we hope to make this package an -"endorsed" implementation of `url_launcher`, so that it is automatically -included in your Flutter Web app when you depend on `package:url_launcher`.)_ - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - url_launcher: ^5.1.4 - url_launcher_web: ^0.1.0 - ... -``` - -### Use the plugin -Once you have the `url_launcher_web` dependency in your pubspec, you should -be able to use `package:url_launcher` as normal. +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -[1]: ../url_launcher +[1]: https://pub.dev/packages/url_launcher +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index 7afdc0af85e2..dbb658d5fb1f 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_web description: Web platform implementation of url_launcher repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_windows/CHANGELOG.md b/packages/url_launcher/url_launcher_windows/CHANGELOG.md index e906254eef44..fca798364f6f 100644 --- a/packages/url_launcher/url_launcher_windows/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.1 + +* Updated installation instructions in README. + ## 2.0.0 * Migrate to null-safety. diff --git a/packages/url_launcher/url_launcher_windows/README.md b/packages/url_launcher/url_launcher_windows/README.md index 4cebb7ed91fb..307f518c4cac 100644 --- a/packages/url_launcher/url_launcher_windows/README.md +++ b/packages/url_launcher/url_launcher_windows/README.md @@ -1,34 +1,11 @@ -# url_launcher_windows +# url\_launcher\_windows The Windows implementation of [`url_launcher`][1]. ## Usage -### Import the package +This package is [endorsed][2], which means you can simply use `shared_preferences` +normally. This package will be automatically included in your app when you do. -This package has been endorsed, meaning that you only need to add `url_launcher` -as a dependency in your `pubspec.yaml`. It will be automatically included in your app -when you depend on `package:url_launcher`. - -This is what the above means to your `pubspec.yaml`: - -```yaml -... -dependencies: - ... - url_launcher: ^5.6.0 - ... -``` - -If you wish to use the Windows package only, you can add `url_launcher_windows` as a -dependency: - -```yaml -... -dependencies: - ... - url_launcher_windows: ^0.0.1 - ... -``` - -[1]: ../url_launcher/url_launcher +[1]: https://pub.dev/packages/url_launcher +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/url_launcher/url_launcher_windows/pubspec.yaml b/packages/url_launcher/url_launcher_windows/pubspec.yaml index 1a82f3e94a43..4d330dd826d5 100644 --- a/packages/url_launcher/url_launcher_windows/pubspec.yaml +++ b/packages/url_launcher/url_launcher_windows/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_windows description: Windows implementation of the url_launcher plugin. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_windows issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.0 +version: 2.0.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index 38bfe90f7b1e..398ec02ba743 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Updated installation instructions in README. + ## 2.0.1 * Fix videos not playing in Safari/Chrome on iOS by setting autoplay to false diff --git a/packages/video_player/video_player_web/README.md b/packages/video_player/video_player_web/README.md index d44f738aeb66..85e55ebcbe80 100644 --- a/packages/video_player/video_player_web/README.md +++ b/packages/video_player/video_player_web/README.md @@ -2,23 +2,11 @@ The web implementation of [`video_player`][1]. - ## Usage -This package is the endorsed implementation of `video_player` for the web platform since version `0.10.5`, so it gets automatically added to your application by depending on `video_player: ^0.10.5`. - -No further modifications to your `pubspec.yaml` should be required in a recent enough version of Flutter (`>=1.12.13+hotfix.4`): - -```yaml -... -dependencies: - ... - video_player: ^0.10.5 - ... -``` - -Once you have the correct `video_player` dependency in your pubspec, you should -be able to use `package:video_player` as normal, even from your web code. +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `video_player` +normally. This package will be automatically included in your app when you do. ## dart:io diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index 568a9262b5f0..f101543598b8 100644 --- a/packages/video_player/video_player_web/pubspec.yaml +++ b/packages/video_player/video_player_web/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_web description: Web platform implementation of video_player. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" From 08c9b40d020bf249f346322ec395220e36889b42 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Wed, 28 Jul 2021 08:39:06 +0200 Subject: [PATCH 164/364] Expand camera platform interface to support pausing the camera preview. (#4191) --- .../camera_platform_interface/CHANGELOG.md | 4 +++ .../method_channel/method_channel_camera.dart | 16 ++++++++++ .../platform_interface/camera_platform.dart | 10 ++++++ .../camera_platform_interface/pubspec.yaml | 2 +- .../test/camera_platform_interface_test.dart | 26 +++++++++++++++ .../method_channel_camera_test.dart | 32 +++++++++++++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/camera/camera_platform_interface/CHANGELOG.md b/packages/camera/camera_platform_interface/CHANGELOG.md index 49214d24d18e..6567d00aa852 100644 --- a/packages/camera/camera_platform_interface/CHANGELOG.md +++ b/packages/camera/camera_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 + +* Introduces interface methods for pausing and resuming the camera preview. + ## 2.0.1 * Update platform_plugin_interface version requirement. diff --git a/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart b/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart index c6c363a56d65..f932f253f491 100644 --- a/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart +++ b/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart @@ -399,6 +399,22 @@ class MethodChannelCamera extends CameraPlatform { } } + @override + Future pausePreview(int cameraId) async { + await _channel.invokeMethod( + 'pausePreview', + {'cameraId': cameraId}, + ); + } + + @override + Future resumePreview(int cameraId) async { + await _channel.invokeMethod( + 'resumePreview', + {'cameraId': cameraId}, + ); + } + @override Widget buildPreview(int cameraId) { return Texture(textureId: cameraId); diff --git a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart index 4437d3b0593a..9e84e8fdf47c 100644 --- a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart +++ b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart @@ -235,6 +235,16 @@ abstract class CameraPlatform extends PlatformInterface { throw UnimplementedError('setZoomLevel() is not implemented.'); } + /// Pause the active preview on the current frame for the selected camera. + Future pausePreview(int cameraId) { + throw UnimplementedError('pausePreview() is not implemented.'); + } + + /// Resume the paused preview for the selected camera. + Future resumePreview(int cameraId) { + throw UnimplementedError('pausePreview() is not implemented.'); + } + /// Returns a widget showing a live camera preview. Widget buildPreview(int cameraId) { throw UnimplementedError('buildView() has not been implemented.'); diff --git a/packages/camera/camera_platform_interface/pubspec.yaml b/packages/camera/camera_platform_interface/pubspec.yaml index def06019c268..d691afd41c21 100644 --- a/packages/camera/camera_platform_interface/pubspec.yaml +++ b/packages/camera/camera_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/camera/camer issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.0.1 +version: 2.1.0 environment: sdk: '>=2.12.0 <3.0.0' diff --git a/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart b/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart index c8f38efc4e2d..750c27200692 100644 --- a/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart +++ b/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart @@ -408,6 +408,32 @@ void main() { throwsUnimplementedError, ); }); + + test( + 'Default implementation of pausePreview() should throw unimplemented error', + () { + // Arrange + final cameraPlatform = ExtendsCameraPlatform(); + + // Act & Assert + expect( + () => cameraPlatform.pausePreview(1), + throwsUnimplementedError, + ); + }); + + test( + 'Default implementation of resumePreview() should throw unimplemented error', + () { + // Arrange + final cameraPlatform = ExtendsCameraPlatform(); + + // Act & Assert + expect( + () => cameraPlatform.resumePreview(1), + throwsUnimplementedError, + ); + }); }); } diff --git a/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart b/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart index 8a618545535b..ec71aa173fff 100644 --- a/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart +++ b/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart @@ -923,6 +923,38 @@ void main() { arguments: {'cameraId': cameraId}), ]); }); + + test('Should pause the camera preview', () async { + // Arrange + MethodChannelMock channel = MethodChannelMock( + channelName: 'plugins.flutter.io/camera', + methods: {'pausePreview': null}, + ); + + // Act + await camera.pausePreview(cameraId); + + // Assert + expect(channel.log, [ + isMethodCall('pausePreview', arguments: {'cameraId': cameraId}), + ]); + }); + + test('Should resume the camera preview', () async { + // Arrange + MethodChannelMock channel = MethodChannelMock( + channelName: 'plugins.flutter.io/camera', + methods: {'resumePreview': null}, + ); + + // Act + await camera.resumePreview(cameraId); + + // Assert + expect(channel.log, [ + isMethodCall('resumePreview', arguments: {'cameraId': cameraId}), + ]); + }); }); }); } From b8786d3ef60169ecf71d7da9264b6727ea726d31 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Wed, 28 Jul 2021 08:44:05 +0200 Subject: [PATCH 165/364] Rebuild camera preview when camera value changes (#4197) --- packages/camera/camera/CHANGELOG.md | 4 +++ .../camera/camera/lib/src/camera_preview.dart | 28 +++++++++++-------- packages/camera/camera/pubspec.yaml | 2 +- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 99156551465d..d455ddb2fad1 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1+7 + +* Fix device orientation sometimes not affecting the camera preview orientation. + ## 0.8.1+6 * Remove references to the Android V1 embedding. diff --git a/packages/camera/camera/lib/src/camera_preview.dart b/packages/camera/camera/lib/src/camera_preview.dart index e2f1ff931e42..ad3175a320a9 100644 --- a/packages/camera/camera/lib/src/camera_preview.dart +++ b/packages/camera/camera/lib/src/camera_preview.dart @@ -21,17 +21,23 @@ class CameraPreview extends StatelessWidget { @override Widget build(BuildContext context) { return controller.value.isInitialized - ? AspectRatio( - aspectRatio: _isLandscape() - ? controller.value.aspectRatio - : (1 / controller.value.aspectRatio), - child: Stack( - fit: StackFit.expand, - children: [ - _wrapInRotatedBox(child: controller.buildPreview()), - child ?? Container(), - ], - ), + ? ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + return AspectRatio( + aspectRatio: _isLandscape() + ? controller.value.aspectRatio + : (1 / controller.value.aspectRatio), + child: Stack( + fit: StackFit.expand, + children: [ + _wrapInRotatedBox(child: controller.buildPreview()), + child ?? Container(), + ], + ), + ); + }, + child: child, ) : Container(); } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 56df2cae0151..57161656fc03 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.8.1+6 +version: 0.8.1+7 environment: sdk: ">=2.12.0 <3.0.0" From f506daabf146e43b1e3365c6ed5a229ff97b5828 Mon Sep 17 00:00:00 2001 From: Ludwik Trammer Date: Wed, 28 Jul 2021 20:44:06 +0200 Subject: [PATCH 166/364] [webview_flutter] Better documentation of Android Platform Views modes (#4187) --- .../webview_flutter/CHANGELOG.md | 4 + .../webview_flutter/webview_flutter/README.md | 96 +++++++++++-------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 4ffdb08928c2..fcfaf4e5720d 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Improved the documentation on using the different Android Platform View modes. + ## 2.0.11 * Remove references to the Android V1 embedding. diff --git a/packages/webview_flutter/webview_flutter/README.md b/packages/webview_flutter/webview_flutter/README.md index 9a613f5f7a8e..a1a98901affb 100644 --- a/packages/webview_flutter/webview_flutter/README.md +++ b/packages/webview_flutter/webview_flutter/README.md @@ -8,7 +8,7 @@ On iOS the WebView widget is backed by a [WKWebView](https://developer.apple.com On Android the WebView widget is backed by a [WebView](https://developer.android.com/reference/android/webkit/WebView). ## Usage -Add `webview_flutter` as a [dependency in your pubspec.yaml file](https://flutter.dev/docs/development/platform-integration/platform-channels). +Add `webview_flutter` as a [dependency in your pubspec.yaml file](https://flutter.dev/docs/development/platform-integration/platform-channels). If you are targeting Android, make sure to read the *Android Platform Views* section below to choose the platform view mode that best suits your needs. You can now include a WebView widget in your widget tree. See the [WebView](https://pub.dev/documentation/webview_flutter/latest/webview_flutter/WebView-class.html) @@ -17,58 +17,78 @@ widget's Dartdoc for more details on how to use the widget. ## Android Platform Views The WebView is relying on [Platform Views](https://flutter.dev/docs/development/platform-integration/platform-views) to embed -the Android’s webview within the Flutter app. By default a Virtual Display based platform view -backend is used, this implementation has multiple -[keyboard issues](https://github.com/flutter/flutter/issues?q=is%3Aopen+label%3Avd-only+label%3A%22p%3A+webview-keyboard%22). -When keyboard input is required we recommend using the Hybrid Composition based platform views -implementation. Note that on Android versions prior to Android 10 Hybrid Composition has some -[performance drawbacks](https://flutter.dev/docs/development/platform-integration/platform-views#performance). +the Android’s webview within the Flutter app. It supports two modes: *Virtual displays* (the current default) and *Hybrid composition*. -### Using Hybrid Composition +Here are some points to consider when choosing between the two: + +* *Hybrid composition* mode has a built-in keyboard support while *Virtual displays* mode has multiple +[keyboard issues](https://github.com/flutter/flutter/issues?q=is%3Aopen+label%3Avd-only+label%3A%22p%3A+webview-keyboard%22) +* *Hybrid composition* mode requires Android SKD 19+ while *Virtual displays* mode requires Android SDK 20+ +* *Hybrid composition* mode has [performence limitations](https://flutter.dev/docs/development/platform-integration/platform-views#performance) when working on Android versions prior to Android 10 while *Virtual displays* is performant on all supported Android versions + +| | Hybrid composition | Virtual displays | +| --------------------------- | ------------------- | ---------------- | +| **Full keyboard supoport** | yes | no | +| **Android SDK support** | 19+ | 20+ | +| **Full performance** | Android 10+ | always | +| **The default** | no | yes | + +### Using Virtual displays -1. Set the `minSdkVersion` in `android/app/build.gradle`: +The mode is currently enabled by default. You should however make sure to set the correct `minSdkVersion` in `android/app/build.gradle` (if it was previously lower than 20): ```groovy android { defaultConfig { - minSdkVersion 19 + minSdkVersion 20 } } ``` -This means that app will only be available for users that run Android SDK 19 or higher. -2. To enable hybrid composition, set `WebView.platform = SurfaceAndroidWebView();` in `initState()`. -For example: - -```dart -import 'dart:io'; +### Using Hybrid Composition -import 'package:webview_flutter/webview_flutter.dart'; +1. Set the correct `minSdkVersion` in `android/app/build.gradle` (if it was previously lower than 19): -class WebViewExample extends StatefulWidget { - @override - WebViewExampleState createState() => WebViewExampleState(); -} - -class WebViewExampleState extends State { - @override - void initState() { - super.initState(); - // Enable hybrid composition. + ```groovy + android { + defaultConfig { + minSdkVersion 19 + } + } + ``` + +2. Set `WebView.platform = SurfaceAndroidWebView();` in `initState()`. + For example: + + ```dart + import 'dart:io'; + + import 'package:webview_flutter/webview_flutter.dart'; + + class WebViewExample extends StatefulWidget { + @override + WebViewExampleState createState() => WebViewExampleState(); + } + + class WebViewExampleState extends State { + @override + void initState() { + super.initState(); + // Enable hybrid composition. if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView(); - } - - @override - Widget build(BuildContext context) { - return WebView( - initialUrl: 'https://flutter.dev', - ); - } -} -``` + } + + @override + Widget build(BuildContext context) { + return WebView( + initialUrl: 'https://flutter.dev', + ); + } + } + ``` -#### Enable Material Components for Android +### Enable Material Components for Android To use Material Components when the user interacts with input elements in the WebView, follow the steps described in the [Enabling Material Components instructions](https://flutter.dev/docs/deployment/android#enabling-material-components). From 1b7c4fbce75073042ef1fe9e92ddafa12f1d3b0b Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 28 Jul 2021 20:49:08 +0200 Subject: [PATCH 167/364] Correct mistake in markdown in README.md (#4201) --- packages/in_app_purchase/in_app_purchase_android/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md index a2f252f8d3ef..dcf5256e3bbc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/README.md +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -7,7 +7,6 @@ The Android implementation of [`in_app_purchase`][1]. This package has been [endorsed][2], meaning that you only need to add `in_app_purchase` as a dependency in your `pubspec.yaml`. This package will be automatically included in your app when you do. -``` If you wish to use the Android package only, you can [add `in_app_purchase_android` directly][3]. From ab953355ca5987482fce84d5a19a4e6ac5c5f0e0 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 28 Jul 2021 20:54:05 +0200 Subject: [PATCH 168/364] Correct mistake with markdown in README.md (#4202) --- packages/in_app_purchase/in_app_purchase_ios/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/README.md b/packages/in_app_purchase/in_app_purchase_ios/README.md index ec72889a8ee2..fcd4834e9cdc 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/README.md +++ b/packages/in_app_purchase/in_app_purchase_ios/README.md @@ -7,7 +7,6 @@ The iOS implementation of [`in_app_purchase`][1]. This package has been [endorsed][2], meaning that you only need to add `in_app_purchase` as a dependency in your `pubspec.yaml`. This package will be automatically included in your app when you do. -``` If you wish to use the iOS package only, you can [add `in_app_purchase_ios` directly][3]. From ed48cff93b74f4eae4c60e1efd5c648835c9cdf7 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Wed, 28 Jul 2021 20:59:05 +0200 Subject: [PATCH 169/364] [camera_web] Add `dispose` implementation (#4203) --- .../integration_test/camera_web_test.dart | 53 +++++++++++++++++-- .../camera/camera_web/lib/src/camera_web.dart | 5 +- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 0b35fcf64234..c72ce47e1e41 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -684,11 +684,54 @@ void main() { ); }); - testWidgets('dispose throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.dispose(cameraId), - throwsUnimplementedError, - ); + group('dispose', () { + testWidgets( + 'throws CameraException ' + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.dispose(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets('disposes the correct camera', (tester) async { + const firstCameraId = 0; + const secondCameraId = 1; + + final firstCamera = MockCamera(); + final secondCamera = MockCamera(); + + when(firstCamera.dispose).thenAnswer((_) => Future.value()); + when(secondCamera.dispose).thenAnswer((_) => Future.value()); + + // Save cameras in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras.addAll({ + firstCameraId: firstCamera, + secondCameraId: secondCamera, + }); + + // Dispose the first camera. + await CameraPlatform.instance.dispose(firstCameraId); + + // The first camera should be disposed. + verify(firstCamera.dispose).called(1); + verifyNever(secondCamera.dispose); + + // The first camera should be removed from the camera plugin. + expect( + (CameraPlatform.instance as CameraPlugin).cameras, + equals({ + secondCameraId: secondCamera, + }), + ); + }); }); group('getCamera', () { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 7a5738db3622..4f130250970c 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -367,8 +367,9 @@ class CameraPlugin extends CameraPlatform { } @override - Future dispose(int cameraId) { - throw UnimplementedError('dispose() is not implemented.'); + Future dispose(int cameraId) async { + getCamera(cameraId).dispose(); + cameras.remove(cameraId); } /// Returns a media video stream for the device with the given [deviceId]. From c8b1d965f6524dc1d58379b49487d466a6df91da Mon Sep 17 00:00:00 2001 From: Yusuf <82844127+ydag@users.noreply.github.com> Date: Thu, 29 Jul 2021 17:04:05 +0200 Subject: [PATCH 170/364] [image_picker] Move UI test (#4181) --- .../image_picker/image_picker/CHANGELOG.md | 4 + .../ios/Runner.xcodeproj/project.pbxproj | 121 +----------------- .../example/ios/RunnerUITestiOS14/Info.plist | 22 ---- .../ImagePickerFromLimitedGalleryUITests.m | 42 +++--- 4 files changed, 25 insertions(+), 164 deletions(-) delete mode 100644 packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/Info.plist rename packages/image_picker/image_picker/example/ios/{RunnerUITestiOS14 => RunnerUITests}/ImagePickerFromLimitedGalleryUITests.m (76%) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index fef3e47cdf1a..e7048c371a95 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Move `ImagePickerFromLimitedGalleryUITests` to `RunnerUITests` target. + ## 0.8.2 * Added new methods that return `package:cross_file` `XFile` instances. [Docs](https://pub.dev/documentation/cross_file/latest/index.html). diff --git a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj index fc1609f5eeda..2f28c9ad2d6d 100644 --- a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj @@ -25,7 +25,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 9FC8F0E9229FA49E00C8D58F /* gifImage.gif in Resources */ = {isa = PBXBuildFile; fileRef = 9FC8F0E8229FA49E00C8D58F /* gifImage.gif */; }; 9FC8F0EC229FA68500C8D58F /* gifImage.gif in Resources */ = {isa = PBXBuildFile; fileRef = 9FC8F0E8229FA49E00C8D58F /* gifImage.gif */; }; - BE7AEE7926403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = BE7AEE7826403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m */; }; + BE6173D826A958B800D0974D /* ImagePickerFromLimitedGalleryUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = BE6173D726A958B800D0974D /* ImagePickerFromLimitedGalleryUITests.m */; }; F4F7A436CCA4BF276270A3AE /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = EC32F6993F4529982D9519F1 /* libPods-Runner.a */; }; /* End PBXBuildFile section */ @@ -44,13 +44,6 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; - BE7AEE7126403C46006181AA /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -100,7 +93,7 @@ 9FC8F0E8229FA49E00C8D58F /* gifImage.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = gifImage.gif; sourceTree = ""; }; 9FC8F0ED229FB90B00C8D58F /* ImageUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImageUtilTests.m; sourceTree = ""; }; A908FAEEA2A9B26D903C09C5 /* libPods-RunnerUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - BE7AEE6C26403C46006181AA /* RunnerUITestiOS14.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITestiOS14.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + BE6173D726A958B800D0974D /* ImagePickerFromLimitedGalleryUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImagePickerFromLimitedGalleryUITests.m; sourceTree = ""; }; BE7AEE7026403C46006181AA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BE7AEE7826403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ImagePickerFromLimitedGalleryUITests.m; sourceTree = ""; }; DC6FCAAD4E7580C9B3C2E21D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -134,13 +127,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BE7AEE6926403C46006181AA /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -171,6 +157,7 @@ 6801C8372555D726009DAF8D /* RunnerUITests */ = { isa = PBXGroup; children = ( + BE6173D726A958B800D0974D /* ImagePickerFromLimitedGalleryUITests.m */, 6801C8382555D726009DAF8D /* ImagePickerFromGalleryUITests.m */, 6801C83A2555D726009DAF8D /* Info.plist */, ); @@ -221,7 +208,6 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 6801C8362555D726009DAF8D /* RunnerUITests.xctest */, - BE7AEE6C26403C46006181AA /* RunnerUITestiOS14.xctest */, 334733F22668136400DCC49E /* RunnerTests.xctest */, ); name = Products; @@ -332,24 +318,6 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; - BE7AEE6B26403C46006181AA /* RunnerUITestiOS14 */ = { - isa = PBXNativeTarget; - buildConfigurationList = BE7AEE7526403C46006181AA /* Build configuration list for PBXNativeTarget "RunnerUITestiOS14" */; - buildPhases = ( - BE7AEE6826403C46006181AA /* Sources */, - BE7AEE6926403C46006181AA /* Frameworks */, - BE7AEE6A26403C46006181AA /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - BE7AEE7226403C46006181AA /* PBXTargetDependency */, - ); - name = RunnerUITestiOS14; - productName = RunnerUITestiOS14; - productReference = BE7AEE6C26403C46006181AA /* RunnerUITestiOS14.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -378,11 +346,6 @@ }; }; }; - BE7AEE6B26403C46006181AA = { - CreatedOnToolsVersion = 12.4; - ProvisioningStyle = Automatic; - TestTargetID = 97C146ED1CF9000F007C117D; - }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -401,7 +364,6 @@ 97C146ED1CF9000F007C117D /* Runner */, 334733F12668136400DCC49E /* RunnerTests */, 6801C8352555D726009DAF8D /* RunnerUITests */, - BE7AEE6B26403C46006181AA /* RunnerUITestiOS14 */, ); }; /* End PBXProject section */ @@ -435,13 +397,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BE7AEE6A26403C46006181AA /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -555,6 +510,7 @@ buildActionMask = 2147483647; files = ( 6801C8392555D726009DAF8D /* ImagePickerFromGalleryUITests.m in Sources */, + BE6173D826A958B800D0974D /* ImagePickerFromLimitedGalleryUITests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -568,14 +524,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BE7AEE6826403C46006181AA /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - BE7AEE7926403CC8006181AA /* ImagePickerFromLimitedGalleryUITests.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -589,11 +537,6 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 6801C83B2555D726009DAF8D /* PBXContainerItemProxy */; }; - BE7AEE7226403C46006181AA /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = BE7AEE7126403C46006181AA /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -843,53 +786,6 @@ }; name = Release; }; - BE7AEE7326403C46006181AA /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = RunnerUITestiOS14/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.baseflow.RunnerUITestiOS14; - PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Runner; - }; - name = Debug; - }; - BE7AEE7426403C46006181AA /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = RunnerUITestiOS14/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.baseflow.RunnerUITestiOS14; - PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Runner; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -929,15 +825,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - BE7AEE7526403C46006181AA /* Build configuration list for PBXNativeTarget "RunnerUITestiOS14" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - BE7AEE7326403C46006181AA /* Debug */, - BE7AEE7426403C46006181AA /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/Info.plist b/packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/Info.plist deleted file mode 100644 index 64d65ca49577..000000000000 --- a/packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/ImagePickerFromLimitedGalleryUITests.m b/packages/image_picker/image_picker/example/ios/RunnerUITests/ImagePickerFromLimitedGalleryUITests.m similarity index 76% rename from packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/ImagePickerFromLimitedGalleryUITests.m rename to packages/image_picker/image_picker/example/ios/RunnerUITests/ImagePickerFromLimitedGalleryUITests.m index 86cad03d27cf..802a494b0f5e 100644 --- a/packages/image_picker/image_picker/example/ios/RunnerUITestiOS14/ImagePickerFromLimitedGalleryUITests.m +++ b/packages/image_picker/image_picker/example/ios/RunnerUITests/ImagePickerFromLimitedGalleryUITests.m @@ -25,31 +25,18 @@ - (void)setUp { __weak typeof(self) weakSelf = self; [self addUIInterruptionMonitorWithDescription:@"Permission popups" handler:^BOOL(XCUIElement* _Nonnull interruptingElement) { - if (@available(iOS 14, *)) { - XCUIElement* limitedPhotoPermission = - [interruptingElement.buttons elementBoundByIndex:0]; - if (![limitedPhotoPermission - waitForExistenceWithTimeout: - kLimitedElementWaitingTime]) { - os_log_error(OS_LOG_DEFAULT, "%@", - weakSelf.app.debugDescription); - XCTFail(@"Failed due to not able to find " - @"selectPhotos butt on with %@ seconds", - @(kLimitedElementWaitingTime)); - } - [limitedPhotoPermission tap]; - } else { - XCUIElement* ok = interruptingElement.buttons[@"OK"]; - if (![ok waitForExistenceWithTimeout: - kLimitedElementWaitingTime]) { - os_log_error(OS_LOG_DEFAULT, "%@", - weakSelf.app.debugDescription); - XCTFail(@"Failed due to not able to find ok button " - @"with %@ seconds", - @(kLimitedElementWaitingTime)); - } - [ok tap]; + XCUIElement* limitedPhotoPermission = + [interruptingElement.buttons elementBoundByIndex:0]; + if (![limitedPhotoPermission + waitForExistenceWithTimeout: + kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", + weakSelf.app.debugDescription); + XCTFail(@"Failed due to not able to find " + @"selectPhotos button with %@ seconds", + @(kLimitedElementWaitingTime)); } + [limitedPhotoPermission tap]; return YES; }]; } @@ -60,7 +47,12 @@ - (void)tearDown { } - (void)testSelectingFromGallery { - [self launchPickerAndSelect]; + // Test the `Select Photos` button which is available after iOS 14. + if (@available(iOS 14, *)) { + [self launchPickerAndSelect]; + } else { + return; + } } - (void)launchPickerAndSelect { From 97fa266174f8f6101aed1857315d6f4cfc81571c Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Fri, 30 Jul 2021 10:14:05 -0700 Subject: [PATCH 171/364] Adds test harnesses for integration tests on Android (#4200) --- .../example/android/app/build.gradle | 9 ++++---- .../googlemapsexample/GoogleMapsTest.java | 23 +++++++++++++++++++ .../MainActivityTest.java | 0 .../android/app/src/debug/AndroidManifest.xml | 17 ++++++++++++++ .../GoogleMapsTestActivity.java | 20 ++++++++++++++++ .../google_maps_flutter/example/pubspec.yaml | 1 + .../example/android/app/build.gradle | 5 +++- .../FlutterActivityTest.java | 0 .../googlesigninexample/GoogleSignInTest.java | 23 +++++++++++++++++++ .../android/app/src/debug/AndroidManifest.xml | 17 ++++++++++++++ .../GoogleSignInTestActivity.java | 20 ++++++++++++++++ .../org.mockito.plugins.MockMaker | 1 - .../google_sign_in/example/pubspec.yaml | 1 + .../example/android/app/build.gradle | 9 ++++---- .../FlutterActivityTest.java | 0 .../imagepickerexample/ImagePickerTest.java | 23 +++++++++++++++++++ .../android/app/src/debug/AndroidManifest.xml | 17 ++++++++++++++ .../ImagePickerTestActivity.java | 20 ++++++++++++++++ ...icker_test.dart => image_picker_test.dart} | 3 +++ .../image_picker/example/pubspec.yaml | 1 + .../example/android/app/build.gradle | 5 ++-- .../FlutterActivityTest.java | 0 .../quickactionsexample/QuickActionsTest.java | 23 +++++++++++++++++++ .../android/app/src/debug/AndroidManifest.xml | 17 ++++++++++++++ .../QuickActionsTestActivity.java | 20 ++++++++++++++++ .../quick_actions/example/pubspec.yaml | 1 + .../configs/exclude_integration_android.yaml | 3 --- 27 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java rename packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/{googlemaps => googlemapsexample}/MainActivityTest.java (100%) create mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/java/io/flutter/plugins/googlemapsexample/GoogleMapsTestActivity.java rename packages/google_sign_in/google_sign_in/example/android/app/src/{main => androidTest}/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java (100%) create mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java create mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java delete mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker rename packages/image_picker/image_picker/example/android/app/src/{main => androidTest}/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java (100%) create mode 100644 packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java create mode 100644 packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java rename packages/image_picker/image_picker/example/integration_test/{old_image_picker_test.dart => image_picker_test.dart} (71%) rename packages/quick_actions/quick_actions/example/android/app/src/{main => androidTest}/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java (100%) create mode 100644 packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java create mode 100644 packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/build.gradle b/packages/google_maps_flutter/google_maps_flutter/example/android/app/build.gradle index 1a8cdf52cc46..d850810db651 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/build.gradle @@ -35,6 +35,7 @@ android { applicationId "io.flutter.plugins.googlemapsexample" minSdkVersion 20 targetSdkVersion 28 + multiDexEnabled true versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -60,11 +61,9 @@ android { dependencies { testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - testImplementation 'androidx.test:core:1.2.0' - testImplementation "org.robolectric:robolectric:4.3.1" - testImplementation 'org.mockito:mockito-core:3.2.4' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' testImplementation 'com.google.android.gms:play-services-maps:17.0.0' } } diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java new file mode 100644 index 000000000000..40552ddf7be1 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.googlemapsexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.googlemaps.GoogleMapsPlugin; +import org.junit.Test; + +public class GoogleMapsTest { + @Test + public void googleMapsPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(GoogleMapsTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(GoogleMapsPlugin.class)); + }); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/MainActivityTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/MainActivityTest.java rename to packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/AndroidManifest.xml b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..9c1f83d3cec5 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/java/io/flutter/plugins/googlemapsexample/GoogleMapsTestActivity.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/java/io/flutter/plugins/googlemapsexample/GoogleMapsTestActivity.java new file mode 100644 index 000000000000..e183a7c75c4e --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/debug/java/io/flutter/plugins/googlemapsexample/GoogleMapsTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 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. + +package io.flutter.plugins.googlemapsexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class GoogleMapsTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml index 74135b31e8d7..d15f76352b69 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: flutter_plugin_android_lifecycle: ^2.0.1 dev_dependencies: + espresso: ^0.1.0+2 flutter_driver: sdk: flutter integration_test: diff --git a/packages/google_sign_in/google_sign_in/example/android/app/build.gradle b/packages/google_sign_in/google_sign_in/example/android/app/build.gradle index 2952c3b9c463..5d574a2c6a51 100644 --- a/packages/google_sign_in/google_sign_in/example/android/app/build.gradle +++ b/packages/google_sign_in/google_sign_in/example/android/app/build.gradle @@ -35,6 +35,7 @@ android { applicationId "io.flutter.plugins.googlesigninexample" minSdkVersion 16 targetSdkVersion 28 + multiDexEnabled true versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -60,5 +61,7 @@ flutter { dependencies { implementation 'com.google.android.gms:play-services-auth:16.0.1' testImplementation'junit:junit:4.12' - testImplementation 'org.mockito:mockito-core:2.17.0' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' } diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java similarity index 100% rename from packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java rename to packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java new file mode 100644 index 000000000000..561d9d4e7a82 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.googlesigninexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.googlesignin.GoogleSignInPlugin; +import org.junit.Test; + +public class GoogleSignInTest { + @Test + public void googleSignInPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(GoogleSignInTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(GoogleSignInPlugin.class)); + }); + } +} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml b/packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..4d764900a530 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java b/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java new file mode 100644 index 000000000000..09506a2632df --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 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. + +package io.flutter.plugins.googlesigninexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class GoogleSignInTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/packages/google_sign_in/google_sign_in/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index 1f0955d450f0..000000000000 --- a/packages/google_sign_in/google_sign_in/example/android/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-inline diff --git a/packages/google_sign_in/google_sign_in/example/pubspec.yaml b/packages/google_sign_in/google_sign_in/example/pubspec.yaml index 8ecfbb6c4369..0379b9065333 100644 --- a/packages/google_sign_in/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/example/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: http: ^0.13.0 dev_dependencies: + espresso: ^0.1.0+2 pedantic: ^1.10.0 integration_test: sdk: flutter diff --git a/packages/image_picker/image_picker/example/android/app/build.gradle b/packages/image_picker/image_picker/example/android/app/build.gradle index cc77d33eed0d..f7fbaae4c9fd 100755 --- a/packages/image_picker/image_picker/example/android/app/build.gradle +++ b/packages/image_picker/image_picker/example/android/app/build.gradle @@ -36,6 +36,7 @@ android { applicationId "io.flutter.plugins.imagepicker.example" minSdkVersion 16 targetSdkVersion 28 + multiDexEnabled true versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -60,9 +61,7 @@ flutter { dependencies { testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-core:3.10.0' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - testImplementation 'androidx.test:core:1.2.0' - testImplementation "org.robolectric:robolectric:4.3.1" + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' } diff --git a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java similarity index 100% rename from packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java rename to packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java diff --git a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java new file mode 100644 index 000000000000..c4a1532d940c --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.imagepickerexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.imagepicker.ImagePickerPlugin; +import org.junit.Test; + +public class ImagePickerTest { + @Test + public void imagePickerPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(ImagePickerTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(ImagePickerPlugin.class)); + }); + } +} diff --git a/packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml b/packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..6f85cefded34 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java b/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java new file mode 100644 index 000000000000..827687a10e79 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 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. + +package io.flutter.plugins.imagepickerexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class ImagePickerTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/image_picker/image_picker/example/integration_test/old_image_picker_test.dart b/packages/image_picker/image_picker/example/integration_test/image_picker_test.dart similarity index 71% rename from packages/image_picker/image_picker/example/integration_test/old_image_picker_test.dart rename to packages/image_picker/image_picker/example/integration_test/image_picker_test.dart index 120c9e221c24..2b82b4bda5e4 100644 --- a/packages/image_picker/image_picker/example/integration_test/old_image_picker_test.dart +++ b/packages/image_picker/image_picker/example/integration_test/image_picker_test.dart @@ -2,8 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('placeholder test', (WidgetTester tester) async {}); } diff --git a/packages/image_picker/image_picker/example/pubspec.yaml b/packages/image_picker/image_picker/example/pubspec.yaml index 422bd5a4120d..44ae0fc22c06 100755 --- a/packages/image_picker/image_picker/example/pubspec.yaml +++ b/packages/image_picker/image_picker/example/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: path: ../ dev_dependencies: + espresso: ^0.1.0+2 flutter_driver: sdk: flutter integration_test: diff --git a/packages/quick_actions/quick_actions/example/android/app/build.gradle b/packages/quick_actions/quick_actions/example/android/app/build.gradle index 57de7f6e5e03..485ae5511063 100644 --- a/packages/quick_actions/quick_actions/example/android/app/build.gradle +++ b/packages/quick_actions/quick_actions/example/android/app/build.gradle @@ -53,6 +53,7 @@ flutter { dependencies { testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' } diff --git a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java similarity index 100% rename from packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java rename to packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java diff --git a/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java new file mode 100644 index 000000000000..9d2fed13fc27 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.quickactionsexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.quickactions.QuickActionsPlugin; +import org.junit.Test; + +public class QuickActionsTest { + @Test + public void imagePickerPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(QuickActionsTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(QuickActionsPlugin.class)); + }); + } +} diff --git a/packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml b/packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..bee689df1735 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java b/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java new file mode 100644 index 000000000000..4ff3a27cd5c0 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 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. + +package io.flutter.plugins.quickactionsexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class QuickActionsTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/quick_actions/quick_actions/example/pubspec.yaml b/packages/quick_actions/quick_actions/example/pubspec.yaml index eaf3de4b56e0..c4ee86039761 100644 --- a/packages/quick_actions/quick_actions/example/pubspec.yaml +++ b/packages/quick_actions/quick_actions/example/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: path: ../ dev_dependencies: + espresso: ^0.1.0+2 flutter_driver: sdk: flutter integration_test: diff --git a/script/configs/exclude_integration_android.yaml b/script/configs/exclude_integration_android.yaml index fc34efa36ac5..d8bd10b3a36e 100644 --- a/script/configs/exclude_integration_android.yaml +++ b/script/configs/exclude_integration_android.yaml @@ -1,9 +1,7 @@ # Currently missing harness files: https://github.com/flutter/flutter/issues/86749) - camera/camera -- google_sign_in/google_sign_in - in_app_purchase/in_app_purchase - in_app_purchase_android -- quick_actions - shared_preferences/shared_preferences - url_launcher/url_launcher - video_player/video_player @@ -17,5 +15,4 @@ - wifi_info_flutter/wifi_info_flutter # No integration tests to run: -- image_picker/image_picker - espresso From 477a13d8b0af92aea5e8e53445b1bfd0ef28f32b Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 30 Jul 2021 21:29:04 +0200 Subject: [PATCH 172/364] [camera_web] Add camera errors handling (#4207) --- .../camera_settings_test.dart | 320 ++++++++- .../example/integration_test/camera_test.dart | 408 +++--------- .../integration_test/camera_web_test.dart | 619 ++++++++++++++---- .../integration_test/helpers/mocks.dart | 53 +- .../camera/camera_web/lib/src/camera.dart | 98 +-- .../camera_web/lib/src/camera_settings.dart | 104 ++- .../camera/camera_web/lib/src/camera_web.dart | 380 +++++++---- .../lib/src/types/camera_error_code.dart | 71 ++ .../lib/src/types/camera_error_codes.dart | 32 - .../lib/src/types/camera_web_exception.dart | 29 + .../camera_web/lib/src/types/types.dart | 3 +- .../camera_web/test/helpers/helpers.dart | 5 + .../camera/camera_web/test/helpers/mocks.dart | 17 + .../test/types/camera_error_code_test.dart | 133 ++++ .../test/types/camera_web_exception_test.dart | 35 + 15 files changed, 1596 insertions(+), 711 deletions(-) create mode 100644 packages/camera/camera_web/lib/src/types/camera_error_code.dart delete mode 100644 packages/camera/camera_web/lib/src/types/camera_error_codes.dart create mode 100644 packages/camera/camera_web/lib/src/types/camera_web_exception.dart create mode 100644 packages/camera/camera_web/test/helpers/helpers.dart create mode 100644 packages/camera/camera_web/test/helpers/mocks.dart create mode 100644 packages/camera/camera_web/test/types/camera_error_code_test.dart create mode 100644 packages/camera/camera_web/test/types/camera_web_exception_test.dart diff --git a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart index ddfb86e4ec0a..7e5119003129 100644 --- a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart @@ -8,6 +8,7 @@ import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -18,6 +19,8 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('CameraSettings', () { + const cameraId = 0; + late Window window; late Navigator navigator; late MediaDevices mediaDevices; @@ -34,9 +37,314 @@ void main() { settings = CameraSettings()..window = window; }); + group('getMediaStreamForOptions', () { + testWidgets( + 'calls MediaDevices.getUserMedia ' + 'with provided options', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenAnswer((_) async => FakeMediaStream([])); + + final options = CameraOptions( + video: VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + width: VideoSizeConstraint(ideal: 200), + ), + ); + + await settings.getMediaStreamForOptions(options); + + verify( + () => mediaDevices.getUserMedia(options.toJson()), + ).called(1); + }); + + testWidgets( + 'throws PlatformException ' + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + expect( + () => settings.getMediaStreamForOptions(CameraOptions()), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported.toString(), + ), + ), + ); + }); + + group('throws CameraWebException', () { + testWidgets( + 'with notFound error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with NotFoundError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotFoundError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.notFound), + ), + ); + }); + + testWidgets( + 'with notFound error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with DevicesNotFoundError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('DevicesNotFoundError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.notFound), + ), + ); + }); + + testWidgets( + 'with notReadable error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with NotReadableError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotReadableError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.notReadable), + ), + ); + }); + + testWidgets( + 'with notReadable error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with TrackStartError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('TrackStartError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.notReadable), + ), + ); + }); + + testWidgets( + 'with overconstrained error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with OverconstrainedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('OverconstrainedError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having( + (e) => e.code, 'code', CameraErrorCode.overconstrained), + ), + ); + }); + + testWidgets( + 'with overconstrained error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with ConstraintNotSatisfiedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('ConstraintNotSatisfiedError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having( + (e) => e.code, 'code', CameraErrorCode.overconstrained), + ), + ); + }); + + testWidgets( + 'with permissionDenied error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with NotAllowedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('NotAllowedError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having( + (e) => e.code, 'code', CameraErrorCode.permissionDenied), + ), + ); + }); + + testWidgets( + 'with permissionDenied error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with PermissionDeniedError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('PermissionDeniedError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having( + (e) => e.code, 'code', CameraErrorCode.permissionDenied), + ), + ); + }); + + testWidgets( + 'with type error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with TypeError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('TypeError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.type), + ), + ); + }); + + testWidgets( + 'with abort error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with AbortError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('AbortError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.abort), + ), + ); + }); + + testWidgets( + 'with security error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with SecurityError', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('SecurityError')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.security), + ), + ); + }); + + testWidgets( + 'with unknown error ' + 'when MediaDevices.getUserMedia throws DomException ' + 'with an unknown error', (tester) async { + when(() => mediaDevices.getUserMedia(any())) + .thenThrow(FakeDomException('Unknown')); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.unknown), + ), + ); + }); + + testWidgets( + 'with unknown error ' + 'when MediaDevices.getUserMedia throws an unknown exception', + (tester) async { + when(() => mediaDevices.getUserMedia(any())).thenThrow(Exception()); + + expect( + () => settings.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.unknown), + ), + ); + }); + }); + }); + group('getFacingModeForVideoTrack', () { testWidgets( - 'throws CameraException ' + 'throws PlatformException ' 'with notSupported error ' 'when there are no media devices', (tester) async { when(() => navigator.mediaDevices).thenReturn(null); @@ -44,10 +352,10 @@ void main() { expect( () => settings.getFacingModeForVideoTrack(MockMediaStreamTrack()), throwsA( - isA().having( + isA().having( (e) => e.code, 'code', - CameraErrorCodes.notSupported, + CameraErrorCode.notSupported.toString(), ), ), ); @@ -145,7 +453,7 @@ void main() { }); testWidgets( - 'throws CameraException ' + 'throws PlatformException ' 'with unknown error ' 'when getting the video track capabilities ' 'throws an unknown error', (tester) async { @@ -157,10 +465,10 @@ void main() { expect( () => settings.getFacingModeForVideoTrack(videoTrack), throwsA( - isA().having( + isA().having( (e) => e.code, 'code', - CameraErrorCodes.unknown, + CameraErrorCode.unknown.toString(), ), ), ); diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index b92e6e34cc59..49690ed38ab5 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -5,10 +5,9 @@ import 'dart:html'; import 'dart:ui'; -import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; -import 'package:camera_web/src/types/camera_error_codes.dart'; -import 'package:camera_web/src/types/camera_options.dart'; +import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -19,27 +18,54 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Camera', () { - late Window window; - late Navigator navigator; late MediaStream mediaStream; - late MediaDevices mediaDevices; + late CameraSettings cameraSettings; setUp(() { - window = MockWindow(); - navigator = MockNavigator(); - mediaDevices = MockMediaDevices(); + cameraSettings = MockCameraSettings(); final videoElement = getVideoElementWithBlankStream(Size(10, 10)); mediaStream = videoElement.captureStream(); - when(() => window.navigator).thenReturn(navigator); - when(() => navigator.mediaDevices).thenReturn(mediaDevices); when( - () => mediaDevices.getUserMedia(any()), - ).thenAnswer((_) async => mediaStream); + () => cameraSettings.getMediaStreamForOptions( + any(), + cameraId: any(named: 'cameraId'), + ), + ).thenAnswer((_) => Future.value(mediaStream)); + }); + + setUpAll(() { + registerFallbackValue(MockCameraOptions()); }); group('initialize', () { + testWidgets( + 'calls CameraSettings.getMediaStreamForOptions ' + 'with provided options', (tester) async { + final options = CameraOptions( + video: VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + width: VideoSizeConstraint(ideal: 200), + ), + ); + + final camera = Camera( + textureId: 1, + options: options, + cameraSettings: cameraSettings, + ); + + await camera.initialize(); + + verify( + () => cameraSettings.getMediaStreamForOptions( + options, + cameraId: 1, + ), + ).called(1); + }); + testWidgets( 'creates a video element ' 'with correct properties', (tester) async { @@ -50,7 +76,7 @@ void main() { options: CameraOptions( audio: audioConstraints, ), - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -75,7 +101,7 @@ void main() { 'with correct properties', (tester) async { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -85,311 +111,24 @@ void main() { expect(camera.divElement.children, contains(camera.videoElement)); }); - testWidgets('calls getUserMedia with provided options', (tester) async { - final options = CameraOptions( - video: VideoConstraints( - facingMode: FacingModeConstraint.exact(CameraType.user), - width: VideoSizeConstraint(ideal: 200), - ), - ); + testWidgets( + 'throws an exception ' + 'when CameraSettings.getMediaStreamForOptions throws', + (tester) async { + final exception = Exception('A media stream exception occured.'); - final optionsJson = await options.toJson(); + when(() => cameraSettings.getMediaStreamForOptions(any(), + cameraId: any(named: 'cameraId'))).thenThrow(exception); final camera = Camera( textureId: 1, - options: options, - window: window, + cameraSettings: cameraSettings, ); - await camera.initialize(); - - verify(() => mediaDevices.getUserMedia(optionsJson)).called(1); - }); - - group('throws CameraException', () { - testWidgets( - 'with notSupported error ' - 'when there are no media devices', (tester) async { - when(() => navigator.mediaDevices).thenReturn(null); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notSupported, - ), - ), - ); - }); - - testWidgets( - 'with notFound error ' - 'when getUserMedia throws DomException ' - 'with NotFoundError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('NotFoundError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notFound, - ), - ), - ); - }); - - testWidgets( - 'with notFound error ' - 'when getUserMedia throws DomException ' - 'with DevicesNotFoundError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('DevicesNotFoundError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notFound, - ), - ), - ); - }); - - testWidgets( - 'with notReadable error ' - 'when getUserMedia throws DomException ' - 'with NotReadableError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('NotReadableError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notReadable, - ), - ), - ); - }); - - testWidgets( - 'with notReadable error ' - 'when getUserMedia throws DomException ' - 'with TrackStartError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('TrackStartError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notReadable, - ), - ), - ); - }); - - testWidgets( - 'with overconstrained error ' - 'when getUserMedia throws DomException ' - 'with OverconstrainedError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('OverconstrainedError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.overconstrained, - ), - ), - ); - }); - - testWidgets( - 'with overconstrained error ' - 'when getUserMedia throws DomException ' - 'with ConstraintNotSatisfiedError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('ConstraintNotSatisfiedError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.overconstrained, - ), - ), - ); - }); - - testWidgets( - 'with permissionDenied error ' - 'when getUserMedia throws DomException ' - 'with NotAllowedError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('NotAllowedError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.permissionDenied, - ), - ), - ); - }); - - testWidgets( - 'with permissionDenied error ' - 'when getUserMedia throws DomException ' - 'with PermissionDeniedError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('PermissionDeniedError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.permissionDenied, - ), - ), - ); - }); - - testWidgets( - 'with type error ' - 'when getUserMedia throws DomException ' - 'with TypeError', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('TypeError')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.type, - ), - ), - ); - }); - - testWidgets( - 'with unknown error ' - 'when getUserMedia throws DomException ' - 'with an unknown error', (tester) async { - when(() => mediaDevices.getUserMedia(any())) - .thenThrow(FakeDomException('Unknown')); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.unknown, - ), - ), - ); - }); - - testWidgets( - 'with unknown error ' - 'when getUserMedia throws an unknown exception', (tester) async { - when(() => mediaDevices.getUserMedia(any())).thenThrow(Exception()); - - final camera = Camera( - textureId: 1, - window: window, - ); - - expect( - camera.initialize, - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.unknown, - ), - ), - ); - }); + expect( + camera.initialize, + throwsA(exception), + ); }); }); @@ -399,35 +138,54 @@ void main() { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); - camera.videoElement.onPlay.listen((event) => startedPlaying = true); + final cameraPlaySubscription = + camera.videoElement.onPlay.listen((event) => startedPlaying = true); await camera.play(); expect(startedPlaying, isTrue); + + await cameraPlaySubscription.cancel(); }); testWidgets( - 'assigns media stream to the video element\'s source ' + 'assigns a media stream ' + 'from CameraSettings.getMediaStreamForOptions ' + 'to the video element\'s source ' 'if it does not exist', (tester) async { + final options = CameraOptions( + video: VideoConstraints( + width: VideoSizeConstraint(ideal: 100), + ), + ); + final camera = Camera( textureId: 1, - window: window, + options: options, + cameraSettings: cameraSettings, ); await camera.initialize(); /// Remove the video element's source /// by stopping the camera. - // ignore: cascade_invocations camera.stop(); await camera.play(); + // Should be called twice: for initialize and play. + verify( + () => cameraSettings.getMediaStreamForOptions( + options, + cameraId: 1, + ), + ).called(2); + expect(camera.videoElement.srcObject, mediaStream); }); }); @@ -436,7 +194,7 @@ void main() { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -452,7 +210,7 @@ void main() { testWidgets('returns a captured picture', (tester) async { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -475,7 +233,7 @@ void main() { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -495,7 +253,7 @@ void main() { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -513,7 +271,7 @@ void main() { final camera = Camera( textureId: textureId, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); @@ -529,7 +287,7 @@ void main() { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( textureId: 1, - window: window, + cameraSettings: cameraSettings, ); await camera.initialize(); diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index c72ce47e1e41..1b540a50e48d 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.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:html'; import 'dart:ui'; @@ -42,9 +43,15 @@ void main() { when(() => window.navigator).thenReturn(navigator); when(() => navigator.mediaDevices).thenReturn(mediaDevices); + when( - () => mediaDevices.getUserMedia(any()), - ).thenAnswer((_) async => videoElement.captureStream()); + () => cameraSettings.getMediaStreamForOptions( + any(), + cameraId: any(named: 'cameraId'), + ), + ).thenAnswer( + (_) async => videoElement.captureStream(), + ); CameraPlatform.instance = CameraPlugin( cameraSettings: cameraSettings, @@ -53,6 +60,7 @@ void main() { setUpAll(() { registerFallbackValue(MockMediaStreamTrack()); + registerFallbackValue(MockCameraOptions()); }); testWidgets('CameraPlugin is the live instance', (tester) async { @@ -66,29 +74,27 @@ void main() { any(), ), ).thenReturn(null); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) async => [], + ); }); - testWidgets( - 'throws CameraException ' - 'with notSupported error ' - 'when there are no media devices', (tester) async { - when(() => navigator.mediaDevices).thenReturn(null); + testWidgets('requests video and audio permissions', (tester) async { + final _ = await CameraPlatform.instance.availableCameras(); - expect( - () => CameraPlatform.instance.availableCameras(), - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notSupported, + verify( + () => cameraSettings.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), ), ), - ); + ).called(1); }); testWidgets( - 'calls MediaDevices.getUserMedia ' - 'on the video input device', (tester) async { + 'gets a video stream ' + 'for a video input device', (tester) async { final videoDevice = FakeMediaDeviceInfo( '1', 'Camera 1', @@ -102,19 +108,47 @@ void main() { final _ = await CameraPlatform.instance.availableCameras(); verify( - () => mediaDevices.getUserMedia( + () => cameraSettings.getMediaStreamForOptions( CameraOptions( video: VideoConstraints( deviceId: videoDevice.deviceId, ), - ).toJson(), + ), ), ).called(1); }); testWidgets( - 'calls CameraSettings.getLensDirectionForVideoTrack ' - 'on the first video track of the video input device', (tester) async { + 'does not get a video stream ' + 'for the video input device ' + 'with an empty device id', (tester) async { + final videoDevice = FakeMediaDeviceInfo( + '', + 'Camera 1', + MediaDeviceKind.videoInput, + ); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) => Future.value([videoDevice]), + ); + + final _ = await CameraPlatform.instance.availableCameras(); + + verifyNever( + () => cameraSettings.getMediaStreamForOptions( + CameraOptions( + video: VideoConstraints( + deviceId: videoDevice.deviceId, + ), + ), + ), + ); + }); + + testWidgets( + 'gets the facing mode ' + 'from the first available video track ' + 'of the video input device', (tester) async { final videoDevice = FakeMediaDeviceInfo( '1', 'Camera 1', @@ -125,10 +159,10 @@ void main() { FakeMediaStream([MockMediaStreamTrack(), MockMediaStreamTrack()]); when( - () => mediaDevices.getUserMedia( + () => cameraSettings.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: videoDevice.deviceId), - ).toJson(), + ), ), ).thenAnswer((_) => Future.value(videoStream)); @@ -147,7 +181,8 @@ void main() { testWidgets( 'returns appropriate camera descriptions ' - 'for multiple media devices', (tester) async { + 'for multiple video devices ' + 'based on video streams', (tester) async { final firstVideoDevice = FakeMediaDeviceInfo( '1', 'Camera 1', @@ -174,35 +209,35 @@ void main() { firstVideoDevice, FakeMediaDeviceInfo( '2', - 'Camera 2', + 'Audio Input 2', MediaDeviceKind.audioInput, ), FakeMediaDeviceInfo( '3', - 'Camera 3', + 'Audio Output 3', MediaDeviceKind.audioOutput, ), secondVideoDevice, ]), ); - // Mock media devices to return the first video stream + // Mock camera settings to return the first video stream // for the first video device. when( - () => mediaDevices.getUserMedia( + () => cameraSettings.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: firstVideoDevice.deviceId), - ).toJson(), + ), ), ).thenAnswer((_) => Future.value(firstVideoStream)); - // Mock media devices to return the second video stream + // Mock camera settings to return the second video stream // for the second video device. when( - () => mediaDevices.getUserMedia( + () => cameraSettings.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: secondVideoDevice.deviceId), - ).toJson(), + ), ), ).thenAnswer((_) => Future.value(secondVideoStream)); @@ -265,10 +300,10 @@ void main() { ); when( - () => mediaDevices.getUserMedia( + () => cameraSettings.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: videoDevice.deviceId), - ).toJson(), + ), ), ).thenAnswer((_) => Future.value(videoStream)); @@ -293,33 +328,93 @@ void main() { }), ); }); - }); - group('createCamera', () { - testWidgets( - 'throws CameraException ' - 'with missingMetadata error ' - 'if there is no metadata ' - 'for the given camera description', (tester) async { - expect( - () => CameraPlatform.instance.createCamera( - CameraDescription( - name: 'name', - lensDirection: CameraLensDirection.back, - sensorOrientation: 0, + group('throws CameraException', () { + testWidgets( + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + expect( + () => CameraPlatform.instance.availableCameras(), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported.toString(), + ), ), - ResolutionPreset.ultraHigh, - ), - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.missingMetadata, + ); + }); + + testWidgets('when MediaDevices.enumerateDevices throws DomException', + (tester) async { + final exception = FakeDomException(DomException.UNKNOWN); + + when(mediaDevices.enumerateDevices).thenThrow(exception); + + expect( + () => CameraPlatform.instance.availableCameras(), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), ), - ), - ); + ); + }); + + testWidgets( + 'when CameraSettings.getMediaStreamForOptions ' + 'throws CameraWebException', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.security, + 'description', + ); + + when(() => cameraSettings.getMediaStreamForOptions(any())) + .thenThrow(exception); + + expect( + () => CameraPlatform.instance.availableCameras(), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + + testWidgets( + 'when CameraSettings.getMediaStreamForOptions ' + 'throws PlatformException', (tester) async { + final exception = PlatformException( + code: CameraErrorCode.notSupported.toString(), + message: 'message', + ); + + when(() => cameraSettings.getMediaStreamForOptions(any())) + .thenThrow(exception); + + expect( + () => CameraPlatform.instance.availableCameras(), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); }); + }); + group('createCamera', () { group('creates a camera', () { const ultraHighResolutionSize = Size(3840, 2160); const maxResolutionSize = Size(3840, 2160); @@ -365,11 +460,6 @@ void main() { 'textureId', cameraId, ) - .having( - (camera) => camera.window, - 'window', - window, - ) .having( (camera) => camera.options, 'options', @@ -426,29 +516,51 @@ void main() { ); }); }); - }); - group('initializeCamera', () { testWidgets( 'throws CameraException ' - 'with notFound error ' - 'if the camera does not exist', (tester) async { + 'with missingMetadata error ' + 'if there is no metadata ' + 'for the given camera description', (tester) async { expect( - () => CameraPlatform.instance.initializeCamera(cameraId), + () => CameraPlatform.instance.createCamera( + CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.back, + sensorOrientation: 0, + ), + ResolutionPreset.ultraHigh, + ), throwsA( isA().having( (e) => e.code, 'code', - CameraErrorCodes.notFound, + CameraErrorCode.missingMetadata.toString(), ), ), ); }); + }); - testWidgets('initializes and plays the camera', (tester) async { - final camera = MockCamera(); + group('initializeCamera', () { + late Camera camera; + late VideoElement videoElement; - when(camera.getVideoSize).thenAnswer((_) => Future.value(Size(10, 10))); + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(Stream.empty())); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(Stream.empty())); + }); + + testWidgets('initializes and plays the camera', (tester) async { + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); when(camera.initialize).thenAnswer((_) => Future.value()); when(camera.play).thenAnswer((_) => Future.value()); @@ -460,6 +572,68 @@ void main() { verify(camera.initialize).called(1); verify(camera.play).called(1); }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when camera throws CameraWebException', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.permissionDenied, + 'description', + ); + + when(camera.initialize).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + + testWidgets('when camera throws DomException', (tester) async { + final exception = FakeDomException(DomException.NOT_ALLOWED); + + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name.toString(), + ), + ), + ); + }); + }); }); testWidgets('lockCaptureOrientation throws UnimplementedError', @@ -482,22 +656,6 @@ void main() { }); group('takePicture', () { - testWidgets( - 'throws CameraException ' - 'with notFound error ' - 'if the camera does not exist', (tester) async { - expect( - () => CameraPlatform.instance.initializeCamera(cameraId), - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notFound, - ), - ), - ); - }); - testWidgets('captures a picture', (tester) async { final camera = MockCamera(); final capturedPicture = MockXFile(); @@ -514,6 +672,44 @@ void main() { expect(picture, equals(capturedPicture)); }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.takePicture(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when takePicture throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(camera.takePicture).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.takePicture(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + }); }); testWidgets('prepareForVideoRecording throws UnimplementedError', @@ -668,7 +864,7 @@ void main() { 'with an appropriate view type', (tester) async { final camera = Camera( textureId: cameraId, - window: window, + cameraSettings: cameraSettings, ); // Save the camera in the camera plugin. @@ -685,22 +881,6 @@ void main() { }); group('dispose', () { - testWidgets( - 'throws CameraException ' - 'with notFound error ' - 'if the camera does not exist', (tester) async { - expect( - () => CameraPlatform.instance.dispose(cameraId), - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCodes.notFound, - ), - ), - ); - }); - testWidgets('disposes the correct camera', (tester) async { const firstCameraId = 0; const secondCameraId = 1; @@ -732,11 +912,86 @@ void main() { }), ); }); + + testWidgets('cancels camera video and abort error subscriptions', + (tester) async { + final camera = MockCamera(); + final videoElement = MockVideoElement(); + + final errorStreamController = StreamController(); + final abortStreamController = StreamController(); + + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.initializeCamera(cameraId); + + expect(errorStreamController.hasListener, isTrue); + expect(abortStreamController.hasListener, isTrue); + + await CameraPlatform.instance.dispose(cameraId); + + expect(errorStreamController.hasListener, isFalse); + expect(abortStreamController.hasListener, isFalse); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.dispose(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when dispose throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_ACCESS); + + when(camera.dispose).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.dispose(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + }); }); group('getCamera', () { testWidgets('returns the correct camera', (tester) async { - final camera = Camera(textureId: cameraId, window: window); + final camera = Camera( + textureId: cameraId, + cameraSettings: cameraSettings, + ); // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; @@ -748,16 +1003,16 @@ void main() { }); testWidgets( - 'throws CameraException ' + 'throws PlatformException ' 'with notFound error ' 'if the camera does not exist', (tester) async { expect( () => (CameraPlatform.instance as CameraPlugin).getCamera(cameraId), throwsA( - isA().having( + isA().having( (e) => e.code, 'code', - CameraErrorCodes.notFound, + CameraErrorCode.notFound.toString(), ), ), ); @@ -774,12 +1029,15 @@ void main() { videoElement = getVideoElementWithBlankStream(videoSize); when( - () => mediaDevices.getUserMedia(any()), + () => cameraSettings.getMediaStreamForOptions( + any(), + cameraId: cameraId, + ), ).thenAnswer((_) async => videoElement.captureStream()); final camera = Camera( textureId: cameraId, - window: window, + cameraSettings: cameraSettings, ); // Save the camera in the camera plugin. @@ -794,14 +1052,16 @@ void main() { expect( await streamQueue.next, - CameraInitializedEvent( - cameraId, - videoSize.width, - videoSize.height, - ExposureMode.auto, - false, - FocusMode.auto, - false, + equals( + CameraInitializedEvent( + cameraId, + videoSize.width, + videoSize.height, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), ), ); @@ -823,11 +1083,126 @@ void main() { ); }); - testWidgets('onCameraError throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.onCameraError(cameraId), - throwsUnimplementedError, - ); + group('onCameraError', () { + late Camera camera; + late VideoElement videoElement; + + late StreamController errorStreamController, + abortStreamController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError).thenAnswer( + (_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort).thenAnswer( + (_) => FakeElementStream(abortStreamController.stream)); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on video error ' + 'with a message', (tester) async { + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + + final error = FakeMediaError( + MediaError.MEDIA_ERR_NETWORK, + 'A network error occured.', + ); + + final errorCode = CameraErrorCode.fromMediaError(error); + + when(() => videoElement.error).thenReturn(error); + + errorStreamController.add(Event('error')); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${errorCode}, error message: ${error.message}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on video error ' + 'with no message', (tester) async { + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + + final error = FakeMediaError(MediaError.MEDIA_ERR_NETWORK); + final errorCode = CameraErrorCode.fromMediaError(error); + + when(() => videoElement.error).thenReturn(error); + + errorStreamController.add(Event('error')); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${errorCode}, error message: No further diagnostic information can be determined or provided.', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on abort error', (tester) async { + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + + abortStreamController.add(Event('abort')); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${CameraErrorCode.abort}, error message: The video element\'s source has not fully loaded.', + ), + ), + ); + + await streamQueue.cancel(); + }); }); testWidgets('onVideoRecordedEvent throws UnimplementedError', diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 54a4f594fe07..8af3a9c3cd81 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -2,11 +2,13 @@ // 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:html'; import 'dart:ui'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/types/types.dart'; import 'package:cross_file/cross_file.dart'; import 'package:mocktail/mocktail.dart'; @@ -22,6 +24,10 @@ class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} class MockCamera extends Mock implements Camera {} +class MockCameraOptions extends Mock implements CameraOptions {} + +class MockVideoElement extends Mock implements VideoElement {} + class MockXFile extends Mock implements XFile {} /// A fake [MediaStream] that returns the provided [_videoTracks]. @@ -52,14 +58,57 @@ class FakeMediaDeviceInfo extends Fake implements MediaDeviceInfo { String? get kind => _kind; } -/// A fake [DomException] that returns the provided error [_name]. +/// A fake [MediaError] that returns the provided error [_code] and [_message]. +class FakeMediaError extends Fake implements MediaError { + FakeMediaError( + this._code, [ + String message = '', + ]) : _message = message; + + final int _code; + final String _message; + + @override + int get code => _code; + + @override + String? get message => _message; +} + +/// A fake [DomException] that returns the provided error [_name] and [_message]. class FakeDomException extends Fake implements DomException { - FakeDomException(this._name); + FakeDomException( + this._name, [ + String? message, + ]) : _message = message; final String _name; + final String? _message; @override String get name => _name; + + @override + String? get message => _message; +} + +/// A fake [ElementStream] that listens to the provided [_stream] on [listen]. +class FakeElementStream extends Fake + implements ElementStream { + FakeElementStream(this._stream); + + final Stream _stream; + + @override + StreamSubscription listen(void onData(T event)?, + {Function? onError, void onDone()?, bool? cancelOnError}) { + return _stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } } /// Returns a video element with a blank stream of size [videoSize]. diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 06551705f056..9e469033dfc4 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -4,17 +4,20 @@ import 'dart:html' as html; import 'dart:ui'; -import 'shims/dart_ui.dart' as ui; import 'package:camera_platform_interface/camera_platform_interface.dart'; -import 'package:camera_web/src/types/camera_error_codes.dart'; -import 'package:camera_web/src/types/camera_options.dart'; +import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/types/types.dart'; + +import 'shims/dart_ui.dart' as ui; String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; -/// A camera initialized from the media devices in the current [window]. -/// The obtained camera is constrained by the [options] used when -/// querying the media input in [_getMediaStream]. +/// A camera initialized from the media devices in the current window. +/// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices +/// +/// The obtained camera stream is constrained by [options] and fetched +/// with [CameraSettings.getMediaStreamForOptions]. /// /// The camera stream is displayed in the [videoElement] wrapped in the /// [divElement] to avoid overriding the custom styles applied to @@ -22,19 +25,19 @@ String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; /// See: https://github.com/flutter/flutter/issues/79519 /// /// The camera can be played/stopped by calling [play]/[stop] -/// or may capture a picture by [takePicture]. +/// or may capture a picture by calling [takePicture]. /// /// The [textureId] is used to register a camera view with the id -/// returned by [_getViewType]. +/// defined by [_getViewType]. class Camera { /// Creates a new instance of [Camera] /// with the given [textureId] and optional /// [options] and [window]. Camera({ required this.textureId, + required CameraSettings cameraSettings, this.options = const CameraOptions(), - html.Window? window, - }) : window = window ?? html.window; + }) : _cameraSettings = cameraSettings; /// The texture id used to register the camera view. final int textureId; @@ -42,9 +45,6 @@ class Camera { /// The camera options used to initialize a camera, empty by default. final CameraOptions options; - /// The current browser window used to access device cameras. - final html.Window window; - /// The video element that displays the camera stream. /// Initialized in [initialize]. late html.VideoElement videoElement; @@ -54,16 +54,16 @@ class Camera { /// Initialized in [initialize]. late html.DivElement divElement; + /// The camera settings used to get the media stream for the camera. + final CameraSettings _cameraSettings; + /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. Future initialize() async { - final isSupported = window.navigator.mediaDevices?.getUserMedia != null; - if (!isSupported) { - throw CameraException( - CameraErrorCodes.notSupported, - 'The camera is not supported on this device.', - ); - } + final stream = await _cameraSettings.getMediaStreamForOptions( + options, + cameraId: textureId, + ); videoElement = html.VideoElement(); _applyDefaultVideoStyles(videoElement); @@ -77,7 +77,6 @@ class Camera { (_) => divElement, ); - final stream = await _getMediaStream(); videoElement ..autoplay = false ..muted = !options.audio.enabled @@ -85,64 +84,15 @@ class Camera { ..setAttribute('playsinline', ''); } - Future _getMediaStream() async { - try { - final constraints = await options.toJson(); - return await window.navigator.mediaDevices!.getUserMedia(constraints); - } on html.DomException catch (e) { - switch (e.name) { - case 'NotFoundError': - case 'DevicesNotFoundError': - throw CameraException( - CameraErrorCodes.notFound, - 'No camera found for the given camera options.', - ); - case 'NotReadableError': - case 'TrackStartError': - throw CameraException( - CameraErrorCodes.notReadable, - 'The camera is not readable due to a hardware error ' - 'that prevented access to the device.', - ); - case 'OverconstrainedError': - case 'ConstraintNotSatisfiedError': - throw CameraException( - CameraErrorCodes.overconstrained, - 'The camera options are impossible to satisfy.', - ); - case 'NotAllowedError': - case 'PermissionDeniedError': - throw CameraException( - CameraErrorCodes.permissionDenied, - 'The camera cannot be used or the permission ' - 'to access the camera is not granted.', - ); - case 'TypeError': - throw CameraException( - CameraErrorCodes.type, - 'The camera options are incorrect or attempted' - 'to access the media input from an insecure context.', - ); - default: - throw CameraException( - CameraErrorCodes.unknown, - 'An unknown error occured when initializing the camera.', - ); - } - } catch (_) { - throw CameraException( - CameraErrorCodes.unknown, - 'An unknown error occured when initializing the camera.', - ); - } - } - /// Starts the camera stream. /// /// Initializes the camera source if the camera was previously stopped. Future play() async { if (videoElement.srcObject == null) { - final stream = await _getMediaStream(); + final stream = await _cameraSettings.getMediaStreamForOptions( + options, + cameraId: textureId, + ); videoElement.srcObject = stream; } await videoElement.play(); diff --git a/packages/camera/camera_web/lib/src/camera_settings.dart b/packages/camera/camera_web/lib/src/camera_settings.dart index 7b87840a90f8..1412248a2371 100644 --- a/packages/camera/camera_web/lib/src/camera_settings.dart +++ b/packages/camera/camera_web/lib/src/camera_settings.dart @@ -8,8 +8,10 @@ import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; -/// A utility to fetch and map camera settings. +/// A utility to fetch, map camera settings and +/// obtain the camera stream. class CameraSettings { // A facing mode constraint name. static const _facingModeKey = "facingMode"; @@ -18,6 +20,93 @@ class CameraSettings { @visibleForTesting html.Window? window = html.window; + /// Returns a media stream associated with the camera device + /// with [cameraId] and constrained by [options]. + Future getMediaStreamForOptions( + CameraOptions options, { + int cameraId = 0, + }) async { + final mediaDevices = window?.navigator.mediaDevices; + + // Throw a not supported exception if the current browser window + // does not support any media devices. + if (mediaDevices == null) { + throw PlatformException( + code: CameraErrorCode.notSupported.toString(), + message: 'The camera is not supported on this device.', + ); + } + + try { + final constraints = await options.toJson(); + return await mediaDevices.getUserMedia(constraints); + } on html.DomException catch (e) { + switch (e.name) { + case 'NotFoundError': + case 'DevicesNotFoundError': + throw CameraWebException( + cameraId, + CameraErrorCode.notFound, + 'No camera found for the given camera options.', + ); + case 'NotReadableError': + case 'TrackStartError': + throw CameraWebException( + cameraId, + CameraErrorCode.notReadable, + 'The camera is not readable due to a hardware error ' + 'that prevented access to the device.', + ); + case 'OverconstrainedError': + case 'ConstraintNotSatisfiedError': + throw CameraWebException( + cameraId, + CameraErrorCode.overconstrained, + 'The camera options are impossible to satisfy.', + ); + case 'NotAllowedError': + case 'PermissionDeniedError': + throw CameraWebException( + cameraId, + CameraErrorCode.permissionDenied, + 'The camera cannot be used or the permission ' + 'to access the camera is not granted.', + ); + case 'TypeError': + throw CameraWebException( + cameraId, + CameraErrorCode.type, + 'The camera options are incorrect or attempted' + 'to access the media input from an insecure context.', + ); + case 'AbortError': + throw CameraWebException( + cameraId, + CameraErrorCode.abort, + 'Some problem occurred that prevented the camera from being used.', + ); + case 'SecurityError': + throw CameraWebException( + cameraId, + CameraErrorCode.security, + 'The user media support is disabled in the current browser.', + ); + default: + throw CameraWebException( + cameraId, + CameraErrorCode.unknown, + 'An unknown error occured when fetching the camera stream.', + ); + } + } catch (_) { + throw CameraWebException( + cameraId, + CameraErrorCode.unknown, + 'An unknown error occured when fetching the camera stream.', + ); + } + } + /// Returns a facing mode of the [videoTrack] /// (null if the facing mode is not available). String? getFacingModeForVideoTrack(html.MediaStreamTrack videoTrack) { @@ -26,9 +115,9 @@ class CameraSettings { // Throw a not supported exception if the current browser window // does not support any media devices. if (mediaDevices == null) { - throw CameraException( - CameraErrorCodes.notSupported, - 'The camera is not supported on this device.', + throw PlatformException( + code: CameraErrorCode.notSupported.toString(), + message: 'The camera is not supported on this device.', ); } @@ -79,9 +168,10 @@ class CameraSettings { // Return null if getting capabilities is currently not supported. return null; default: - throw CameraException( - CameraErrorCodes.unknown, - 'An unknown error occured when getting the video track capabilities.', + throw PlatformException( + code: CameraErrorCode.unknown.toString(), + message: + 'An unknown error occured when getting the video track capabilities.', ); } } diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 4f130250970c..35241d0c9b8b 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -15,6 +15,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:stream_transform/stream_transform.dart'; +// The default error message, when the error is an empty string. +// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/message +const String _kDefaultErrorMessage = + 'No further diagnostic information can be determined or provided.'; + /// The web implementation of [CameraPlatform]. /// /// This class implements the `package:camera` functionality for the web. @@ -50,6 +55,12 @@ class CameraPlugin extends CameraPlatform { @visibleForTesting final cameraEventStreamController = StreamController.broadcast(); + final _cameraVideoErrorSubscriptions = + >{}; + + final _cameraVideoAbortSubscriptions = + >{}; + /// Returns a stream of camera events for the given [cameraId]. Stream _cameraEvents(int cameraId) => cameraEventStreamController.stream @@ -61,87 +72,103 @@ class CameraPlugin extends CameraPlatform { @override Future> availableCameras() async { - final mediaDevices = window?.navigator.mediaDevices; - final cameras = []; - - // Throw a not supported exception if the current browser window - // does not support any media devices. - if (mediaDevices == null) { - throw CameraException( - CameraErrorCodes.notSupported, - 'The camera is not supported on this device.', - ); - } - - // Request available media devices. - final devices = await mediaDevices.enumerateDevices(); - - // Filter video input devices. - final videoInputDevices = devices - .whereType() - .where((device) => device.kind == MediaDeviceKind.videoInput) - - /// The device id property is currently not supported on Internet Explorer: - /// https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/deviceId#browser_compatibility - .where((device) => device.deviceId != null); - - // Map video input devices to camera descriptions. - for (final videoInputDevice in videoInputDevices) { - // Get the video stream for the current video input device - // to later use for the available video tracks. - final videoStream = await _getVideoStreamForDevice( - mediaDevices, - videoInputDevice.deviceId!, - ); - - // Get all video tracks in the video stream - // to later extract the lens direction from the first track. - final videoTracks = videoStream.getVideoTracks(); - - if (videoTracks.isNotEmpty) { - // Get the facing mode from the first available video track. - final facingMode = _cameraSettings.getFacingModeForVideoTrack( - videoTracks.first, + try { + final mediaDevices = window?.navigator.mediaDevices; + final cameras = []; + + // Throw a not supported exception if the current browser window + // does not support any media devices. + if (mediaDevices == null) { + throw PlatformException( + code: CameraErrorCode.notSupported.toString(), + message: 'The camera is not supported on this device.', ); + } - // Get the lens direction based on the facing mode. - // Fallback to the external lens direction - // if the facing mode is not available. - final lensDirection = facingMode != null - ? _cameraSettings.mapFacingModeToLensDirection(facingMode) - : CameraLensDirection.external; - - // Create a camera description. - // - // The name is a camera label which might be empty - // if no permissions to media devices have been granted. - // - // MediaDeviceInfo.label: - // https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/label - // - // Sensor orientation is currently not supported. - final cameraLabel = videoInputDevice.label ?? ''; - final camera = CameraDescription( - name: cameraLabel, - lensDirection: lensDirection, - sensorOrientation: 0, - ); + // Request video and audio permissions. + await _cameraSettings.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), + ), + ); - final cameraMetadata = CameraMetadata( - deviceId: videoInputDevice.deviceId!, - facingMode: facingMode, + // Request available media devices. + final devices = await mediaDevices.enumerateDevices(); + + // Filter video input devices. + final videoInputDevices = devices + .whereType() + .where((device) => device.kind == MediaDeviceKind.videoInput) + + /// The device id property is currently not supported on Internet Explorer: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/deviceId#browser_compatibility + .where( + (device) => device.deviceId != null && device.deviceId!.isNotEmpty, + ); + + // Map video input devices to camera descriptions. + for (final videoInputDevice in videoInputDevices) { + // Get the video stream for the current video input device + // to later use for the available video tracks. + final videoStream = await _getVideoStreamForDevice( + videoInputDevice.deviceId!, ); - cameras.add(camera); - - camerasMetadata[camera] = cameraMetadata; - } else { - // Ignore as no video tracks exist in the current video input device. - continue; + // Get all video tracks in the video stream + // to later extract the lens direction from the first track. + final videoTracks = videoStream.getVideoTracks(); + + if (videoTracks.isNotEmpty) { + // Get the facing mode from the first available video track. + final facingMode = + _cameraSettings.getFacingModeForVideoTrack(videoTracks.first); + + // Get the lens direction based on the facing mode. + // Fallback to the external lens direction + // if the facing mode is not available. + final lensDirection = facingMode != null + ? _cameraSettings.mapFacingModeToLensDirection(facingMode) + : CameraLensDirection.external; + + // Create a camera description. + // + // The name is a camera label which might be empty + // if no permissions to media devices have been granted. + // + // MediaDeviceInfo.label: + // https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo/label + // + // Sensor orientation is currently not supported. + final cameraLabel = videoInputDevice.label ?? ''; + final camera = CameraDescription( + name: cameraLabel, + lensDirection: lensDirection, + sensorOrientation: 0, + ); + + final cameraMetadata = CameraMetadata( + deviceId: videoInputDevice.deviceId!, + facingMode: facingMode, + ); + + cameras.add(camera); + + camerasMetadata[camera] = cameraMetadata; + } else { + // Ignore as no video tracks exist in the current video input device. + continue; + } } - } - return cameras; + return cameras; + } on html.DomException catch (e) { + throw CameraException(e.name, e.message); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw CameraException(e.code.toString(), e.description); + } } @override @@ -150,50 +177,56 @@ class CameraPlugin extends CameraPlatform { ResolutionPreset? resolutionPreset, { bool enableAudio = false, }) async { - if (!camerasMetadata.containsKey(cameraDescription)) { - throw CameraException( - CameraErrorCodes.missingMetadata, - 'Missing camera metadata. Make sure to call `availableCameras` before creating a camera.', - ); - } + try { + if (!camerasMetadata.containsKey(cameraDescription)) { + throw PlatformException( + code: CameraErrorCode.missingMetadata.toString(), + message: + 'Missing camera metadata. Make sure to call `availableCameras` before creating a camera.', + ); + } - final textureId = _textureCounter++; - - final cameraMetadata = camerasMetadata[cameraDescription]!; - - final cameraType = cameraMetadata.facingMode != null - ? _cameraSettings.mapFacingModeToCameraType(cameraMetadata.facingMode!) - : null; - - // Use the highest resolution possible - // if the resolution preset is not specified. - final videoSize = _cameraSettings - .mapResolutionPresetToSize(resolutionPreset ?? ResolutionPreset.max); - - // Create a camera with the given audio and video constraints. - // Sensor orientation is currently not supported. - final camera = Camera( - textureId: textureId, - window: window, - options: CameraOptions( - audio: AudioConstraints(enabled: enableAudio), - video: VideoConstraints( - facingMode: - cameraType != null ? FacingModeConstraint(cameraType) : null, - width: VideoSizeConstraint( - ideal: videoSize.width.toInt(), - ), - height: VideoSizeConstraint( - ideal: videoSize.height.toInt(), + final textureId = _textureCounter++; + + final cameraMetadata = camerasMetadata[cameraDescription]!; + + final cameraType = cameraMetadata.facingMode != null + ? _cameraSettings + .mapFacingModeToCameraType(cameraMetadata.facingMode!) + : null; + + // Use the highest resolution possible + // if the resolution preset is not specified. + final videoSize = _cameraSettings + .mapResolutionPresetToSize(resolutionPreset ?? ResolutionPreset.max); + + // Create a camera with the given audio and video constraints. + // Sensor orientation is currently not supported. + final camera = Camera( + textureId: textureId, + cameraSettings: _cameraSettings, + options: CameraOptions( + audio: AudioConstraints(enabled: enableAudio), + video: VideoConstraints( + facingMode: + cameraType != null ? FacingModeConstraint(cameraType) : null, + width: VideoSizeConstraint( + ideal: videoSize.width.toInt(), + ), + height: VideoSizeConstraint( + ideal: videoSize.height.toInt(), + ), + deviceId: cameraMetadata.deviceId, ), - deviceId: cameraMetadata.deviceId, ), - ), - ); + ); - cameras[textureId] = camera; + cameras[textureId] = camera; - return textureId; + return textureId; + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } } @override @@ -202,26 +235,66 @@ class CameraPlugin extends CameraPlatform { // The image format group is currently not supported. ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, }) async { - final camera = getCamera(cameraId); - - await camera.initialize(); - await camera.play(); - - final cameraSize = await camera.getVideoSize(); - - cameraEventStreamController.add( - CameraInitializedEvent( - cameraId, - cameraSize.width, - cameraSize.height, - // TODO(camera_web): Add support for exposure mode and point (https://github.com/flutter/flutter/issues/86857). - ExposureMode.auto, - false, - // TODO(camera_web): Add support for focus mode and point (https://github.com/flutter/flutter/issues/86858). - FocusMode.auto, - false, - ), - ); + try { + final camera = getCamera(cameraId); + + await camera.initialize(); + + // Add camera's video error events to the camera events stream. + // The error event fires when the video element's source has failed to load, or can't be used. + _cameraVideoErrorSubscriptions[cameraId] = + camera.videoElement.onError.listen((html.Event _) { + // The Event itself (_) doesn't contain information about the actual error. + // We need to look at the HTMLMediaElement.error. + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error + final error = camera.videoElement.error!; + final errorCode = CameraErrorCode.fromMediaError(error); + final errorMessage = + error.message != '' ? error.message : _kDefaultErrorMessage; + + cameraEventStreamController.add( + CameraErrorEvent( + cameraId, + 'Error code: ${errorCode}, error message: ${errorMessage}', + ), + ); + }); + + // Add camera's video abort events to the camera events stream. + // The abort event fires when the video element's source has not fully loaded. + _cameraVideoAbortSubscriptions[cameraId] = + camera.videoElement.onAbort.listen((html.Event _) { + cameraEventStreamController.add( + CameraErrorEvent( + cameraId, + 'Error code: ${CameraErrorCode.abort}, error message: The video element\'s source has not fully loaded.', + ), + ); + }); + + await camera.play(); + + final cameraSize = await camera.getVideoSize(); + + cameraEventStreamController.add( + CameraInitializedEvent( + cameraId, + cameraSize.width, + cameraSize.height, + // TODO(camera_web): Add support for exposure mode and point (https://github.com/flutter/flutter/issues/86857). + ExposureMode.auto, + false, + // TODO(camera_web): Add support for focus mode and point (https://github.com/flutter/flutter/issues/86858). + FocusMode.auto, + false, + ), + ); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override @@ -241,7 +314,7 @@ class CameraPlugin extends CameraPlatform { @override Stream onCameraError(int cameraId) { - throw UnimplementedError('onCameraError() is not implemented.'); + return _cameraEvents(cameraId).whereType(); } @override @@ -271,7 +344,11 @@ class CameraPlugin extends CameraPlatform { @override Future takePicture(int cameraId) { - return getCamera(cameraId).takePicture(); + try { + return getCamera(cameraId).takePicture(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } } @override @@ -368,13 +445,21 @@ class CameraPlugin extends CameraPlatform { @override Future dispose(int cameraId) async { - getCamera(cameraId).dispose(); - cameras.remove(cameraId); + try { + getCamera(cameraId).dispose(); + await _cameraVideoErrorSubscriptions[cameraId]?.cancel(); + await _cameraVideoAbortSubscriptions[cameraId]?.cancel(); + + cameras.remove(cameraId); + _cameraVideoErrorSubscriptions.remove(cameraId); + _cameraVideoAbortSubscriptions.remove(cameraId); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } } /// Returns a media video stream for the device with the given [deviceId]. Future _getVideoStreamForDevice( - html.MediaDevices mediaDevices, String deviceId, ) { // Create camera options with the desired device id. @@ -382,7 +467,7 @@ class CameraPlugin extends CameraPlatform { video: VideoConstraints(deviceId: deviceId), ); - return mediaDevices.getUserMedia(cameraOptions.toJson()); + return _cameraSettings.getMediaStreamForOptions(cameraOptions); } /// Returns a camera for the given [cameraId]. @@ -393,12 +478,23 @@ class CameraPlugin extends CameraPlatform { final camera = cameras[cameraId]; if (camera == null) { - throw CameraException( - CameraErrorCodes.notFound, - 'No camera found for the given camera id $cameraId.', + throw PlatformException( + code: CameraErrorCode.notFound.toString(), + message: 'No camera found for the given camera id $cameraId.', ); } return camera; } + + /// Adds a [CameraErrorEvent], associated with the [exception], + /// to the stream of camera events. + void _addCameraErrorEvent(CameraWebException exception) { + cameraEventStreamController.add( + CameraErrorEvent( + exception.cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ); + } } diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart new file mode 100644 index 000000000000..3dcace3ca2d6 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -0,0 +1,71 @@ +// Copyright 2013 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:html' as html; + +/// Error codes that may occur during the camera initialization, +/// configuration or video streaming. +class CameraErrorCode { + const CameraErrorCode._(this._type); + + final String _type; + + @override + String toString() => _type; + + /// The camera is not supported. + static const CameraErrorCode notSupported = + CameraErrorCode._('cameraNotSupported'); + + /// The camera is not found. + static const CameraErrorCode notFound = CameraErrorCode._('cameraNotFound'); + + /// The camera is not readable. + static const CameraErrorCode notReadable = + CameraErrorCode._('cameraNotReadable'); + + /// The camera options are impossible to satisfy. + static const CameraErrorCode overconstrained = + CameraErrorCode._('cameraOverconstrained'); + + /// The camera cannot be used or the permission + /// to access the camera is not granted. + static const CameraErrorCode permissionDenied = + CameraErrorCode._('cameraPermission'); + + /// The camera options are incorrect or attempted + /// to access the media input from an insecure context. + static const CameraErrorCode type = CameraErrorCode._('cameraType'); + + /// Some problem occurred that prevented the camera from being used. + static const CameraErrorCode abort = CameraErrorCode._('cameraAbort'); + + /// The user media support is disabled in the current browser. + static const CameraErrorCode security = CameraErrorCode._('cameraSecurity'); + + /// The camera metadata is missing. + static const CameraErrorCode missingMetadata = + CameraErrorCode._('cameraMissingMetadata'); + + /// An unknown camera error. + static const CameraErrorCode unknown = CameraErrorCode._('cameraUnknown'); + + /// Returns a camera error code based on the media error. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code + static CameraErrorCode fromMediaError(html.MediaError error) { + switch (error.code) { + case html.MediaError.MEDIA_ERR_ABORTED: + return CameraErrorCode._('mediaErrorAborted'); + case html.MediaError.MEDIA_ERR_NETWORK: + return CameraErrorCode._('mediaErrorNetwork'); + case html.MediaError.MEDIA_ERR_DECODE: + return CameraErrorCode._('mediaErrorDecode'); + case html.MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + return CameraErrorCode._('mediaErrorSourceNotSupported'); + default: + return CameraErrorCode._('mediaErrorUnknown'); + } + } +} diff --git a/packages/camera/camera_web/lib/src/types/camera_error_codes.dart b/packages/camera/camera_web/lib/src/types/camera_error_codes.dart deleted file mode 100644 index afb02ae3aaa9..000000000000 --- a/packages/camera/camera_web/lib/src/types/camera_error_codes.dart +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2013 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. - -/// Error codes that may occur during the camera initialization or streaming. -abstract class CameraErrorCodes { - /// The camera is not supported. - static const notSupported = 'cameraNotSupported'; - - /// The camera is not found. - static const notFound = 'cameraNotFound'; - - /// The camera is not readable. - static const notReadable = 'cameraNotReadable'; - - /// The camera options are impossible to satisfy. - static const overconstrained = 'cameraOverconstrained'; - - /// The camera cannot be used or the permission - /// to access the camera is not granted. - static const permissionDenied = 'cameraPermission'; - - /// The camera options are incorrect or attempted - /// to access the media input from an insecure context. - static const type = 'cameraType'; - - /// The camera metadata is missing. - static const missingMetadata = 'missingMetadata'; - - /// An unknown camera error. - static const unknown = 'cameraUnknown'; -} diff --git a/packages/camera/camera_web/lib/src/types/camera_web_exception.dart b/packages/camera/camera_web/lib/src/types/camera_web_exception.dart new file mode 100644 index 000000000000..c21106cc462e --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_web_exception.dart @@ -0,0 +1,29 @@ +// Copyright 2013 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 'package:camera_web/src/types/types.dart'; + +/// An exception thrown when the camera with id [cameraId] reports +/// an initialization, configuration or video streaming error, +/// or enters into an unexpected state. +/// +/// This error should be emitted on the `onCameraError` stream +/// of the camera platform. +class CameraWebException implements Exception { + /// Creates a new instance of [CameraWebException] + /// with the given error [cameraId], [code] and [description]. + CameraWebException(this.cameraId, this.code, this.description); + + /// The id of the camera this exception is associated to. + int cameraId; + + /// The error code of this exception. + CameraErrorCode code; + + /// The description of this exception. + String description; + + @override + String toString() => 'CameraWebException($cameraId, $code, $description)'; +} diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart index 1a15503715cd..788ec79de205 100644 --- a/packages/camera/camera_web/lib/src/types/types.dart +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -export 'camera_error_codes.dart'; +export 'camera_error_code.dart'; export 'camera_metadata.dart'; export 'camera_options.dart'; +export 'camera_web_exception.dart'; export 'media_device_kind.dart'; diff --git a/packages/camera/camera_web/test/helpers/helpers.dart b/packages/camera/camera_web/test/helpers/helpers.dart new file mode 100644 index 000000000000..7094f55bb62e --- /dev/null +++ b/packages/camera/camera_web/test/helpers/helpers.dart @@ -0,0 +1,5 @@ +// Copyright 2013 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. + +export 'mocks.dart'; diff --git a/packages/camera/camera_web/test/helpers/mocks.dart b/packages/camera/camera_web/test/helpers/mocks.dart new file mode 100644 index 000000000000..0398ad33f126 --- /dev/null +++ b/packages/camera/camera_web/test/helpers/mocks.dart @@ -0,0 +1,17 @@ +// Copyright 2013 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:html'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A fake [MediaError] that returns the provided error [_code]. +class FakeMediaError extends Fake implements MediaError { + FakeMediaError(this._code); + + final int _code; + + @override + int get code => _code; +} diff --git a/packages/camera/camera_web/test/types/camera_error_code_test.dart b/packages/camera/camera_web/test/types/camera_error_code_test.dart new file mode 100644 index 000000000000..ca896e8696d7 --- /dev/null +++ b/packages/camera/camera_web/test/types/camera_error_code_test.dart @@ -0,0 +1,133 @@ +// Copyright 2013 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:html'; + +import 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/helpers.dart'; + +void main() { + group('CameraErrorCode', () { + group('toString returns a correct type for', () { + test('notSupported', () { + expect( + CameraErrorCode.notSupported.toString(), + equals('cameraNotSupported'), + ); + }); + + test('notFound', () { + expect( + CameraErrorCode.notFound.toString(), + equals('cameraNotFound'), + ); + }); + + test('notReadable', () { + expect( + CameraErrorCode.notReadable.toString(), + equals('cameraNotReadable'), + ); + }); + + test('overconstrained', () { + expect( + CameraErrorCode.overconstrained.toString(), + equals('cameraOverconstrained'), + ); + }); + + test('permissionDenied', () { + expect( + CameraErrorCode.permissionDenied.toString(), + equals('cameraPermission'), + ); + }); + + test('type', () { + expect( + CameraErrorCode.type.toString(), + equals('cameraType'), + ); + }); + + test('abort', () { + expect( + CameraErrorCode.abort.toString(), + equals('cameraAbort'), + ); + }); + + test('security', () { + expect( + CameraErrorCode.security.toString(), + equals('cameraSecurity'), + ); + }); + + test('missingMetadata', () { + expect( + CameraErrorCode.missingMetadata.toString(), + equals('cameraMissingMetadata'), + ); + }); + + test('unknown', () { + expect( + CameraErrorCode.unknown.toString(), + equals('cameraUnknown'), + ); + }); + + group('fromMediaError', () { + test('with aborted error code', () { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_ABORTED), + ).toString(), + equals('mediaErrorAborted'), + ); + }); + + test('with network error code', () { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_NETWORK), + ).toString(), + equals('mediaErrorNetwork'), + ); + }); + + test('with decode error code', () { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_DECODE), + ).toString(), + equals('mediaErrorDecode'), + ); + }); + + test('with source not supported error code', () { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED), + ).toString(), + equals('mediaErrorSourceNotSupported'), + ); + }); + + test('with unknown error code', () { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(5), + ).toString(), + equals('mediaErrorUnknown'), + ); + }); + }); + }); + }); +} diff --git a/packages/camera/camera_web/test/types/camera_web_exception_test.dart b/packages/camera/camera_web/test/types/camera_web_exception_test.dart new file mode 100644 index 000000000000..d58512b460e2 --- /dev/null +++ b/packages/camera/camera_web/test/types/camera_web_exception_test.dart @@ -0,0 +1,35 @@ +// Copyright 2013 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 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CameraWebException', () { + test('sets all properties', () { + final cameraId = 1; + final code = CameraErrorCode.notFound; + final description = 'The camera is not found.'; + + final exception = CameraWebException(cameraId, code, description); + + expect(exception.cameraId, equals(cameraId)); + expect(exception.code, equals(code)); + expect(exception.description, equals(description)); + }); + + test('toString includes all properties', () { + final cameraId = 2; + final code = CameraErrorCode.notReadable; + final description = 'The camera is not readable.'; + + final exception = CameraWebException(cameraId, code, description); + + expect( + exception.toString(), + equals('CameraWebException($cameraId, $code, $description)'), + ); + }); + }); +} From a3accd7c2fc72150c225a919d336819066759620 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Mon, 2 Aug 2021 09:52:06 +0200 Subject: [PATCH 173/364] Replace reference to shared_preferences with url_launcher (#4211) --- packages/url_launcher/url_launcher_linux/CHANGELOG.md | 4 ++++ packages/url_launcher/url_launcher_linux/README.md | 2 +- packages/url_launcher/url_launcher_linux/pubspec.yaml | 2 +- packages/url_launcher/url_launcher_macos/CHANGELOG.md | 4 ++++ packages/url_launcher/url_launcher_macos/README.md | 2 +- packages/url_launcher/url_launcher_macos/pubspec.yaml | 2 +- packages/url_launcher/url_launcher_web/CHANGELOG.md | 4 ++++ packages/url_launcher/url_launcher_web/README.md | 2 +- packages/url_launcher/url_launcher_web/pubspec.yaml | 2 +- packages/url_launcher/url_launcher_windows/CHANGELOG.md | 4 ++++ packages/url_launcher/url_launcher_windows/README.md | 2 +- packages/url_launcher/url_launcher_windows/pubspec.yaml | 2 +- 12 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/url_launcher/url_launcher_linux/CHANGELOG.md b/packages/url_launcher/url_launcher_linux/CHANGELOG.md index b872a55ef161..147d0f312c7e 100644 --- a/packages/url_launcher/url_launcher_linux/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + ## 2.0.1 * Updated installation instructions in README. diff --git a/packages/url_launcher/url_launcher_linux/README.md b/packages/url_launcher/url_launcher_linux/README.md index 23c0019b6948..1d0667860030 100644 --- a/packages/url_launcher/url_launcher_linux/README.md +++ b/packages/url_launcher/url_launcher_linux/README.md @@ -4,7 +4,7 @@ The Linux implementation of [`url_launcher`][1]. ## Usage -This package is [endorsed][2], which means you can simply use `shared_preferences` +This package is [endorsed][2], which means you can simply use `url_launcher` normally. This package will be automatically included in your app when you do. [1]: https://pub.dev/packages/url_launcher diff --git a/packages/url_launcher/url_launcher_linux/pubspec.yaml b/packages/url_launcher/url_launcher_linux/pubspec.yaml index e08011e496d5..960216851e5d 100644 --- a/packages/url_launcher/url_launcher_linux/pubspec.yaml +++ b/packages/url_launcher/url_launcher_linux/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_linux description: Linux implementation of the url_launcher plugin. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_linux issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_macos/CHANGELOG.md b/packages/url_launcher/url_launcher_macos/CHANGELOG.md index 2f672940f2ac..96d2fd49c7e7 100644 --- a/packages/url_launcher/url_launcher_macos/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_macos/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + ## 2.0.1 * Add native unit tests. diff --git a/packages/url_launcher/url_launcher_macos/README.md b/packages/url_launcher/url_launcher_macos/README.md index b594cde1d041..0869f0ce9940 100644 --- a/packages/url_launcher/url_launcher_macos/README.md +++ b/packages/url_launcher/url_launcher_macos/README.md @@ -4,7 +4,7 @@ The macos implementation of [`url_launcher`][1]. ## Usage -This package is [endorsed][2], which means you can simply use `shared_preferences` +This package is [endorsed][2], which means you can simply use `url_launcher` normally. This package will be automatically included in your app when you do. [1]: https://pub.dev/packages/url_launcher diff --git a/packages/url_launcher/url_launcher_macos/pubspec.yaml b/packages/url_launcher/url_launcher_macos/pubspec.yaml index 2483e35e56de..534830000626 100644 --- a/packages/url_launcher/url_launcher_macos/pubspec.yaml +++ b/packages/url_launcher/url_launcher_macos/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_macos description: macOS implementation of the url_launcher plugin. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_macos issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index b1fff136793d..64830f5e4481 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.3 + +- Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + ## 2.0.2 - Updated installation instructions in README. diff --git a/packages/url_launcher/url_launcher_web/README.md b/packages/url_launcher/url_launcher_web/README.md index b03d15478ee3..8043c9fa07ff 100644 --- a/packages/url_launcher/url_launcher_web/README.md +++ b/packages/url_launcher/url_launcher_web/README.md @@ -4,7 +4,7 @@ The web implementation of [`url_launcher`][1]. ## Usage -This package is [endorsed][2], which means you can simply use `shared_preferences` +This package is [endorsed][2], which means you can simply use `url_launcher` normally. This package will be automatically included in your app when you do. [1]: https://pub.dev/packages/url_launcher diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index dbb658d5fb1f..cba098daceb7 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_web description: Web platform implementation of url_launcher repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.2 +version: 2.0.3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher_windows/CHANGELOG.md b/packages/url_launcher/url_launcher_windows/CHANGELOG.md index fca798364f6f..d26fe19c359e 100644 --- a/packages/url_launcher/url_launcher_windows/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + ## 2.0.1 * Updated installation instructions in README. diff --git a/packages/url_launcher/url_launcher_windows/README.md b/packages/url_launcher/url_launcher_windows/README.md index 307f518c4cac..cd7b6d47eeb2 100644 --- a/packages/url_launcher/url_launcher_windows/README.md +++ b/packages/url_launcher/url_launcher_windows/README.md @@ -4,7 +4,7 @@ The Windows implementation of [`url_launcher`][1]. ## Usage -This package is [endorsed][2], which means you can simply use `shared_preferences` +This package is [endorsed][2], which means you can simply use `url_launcher` normally. This package will be automatically included in your app when you do. [1]: https://pub.dev/packages/url_launcher diff --git a/packages/url_launcher/url_launcher_windows/pubspec.yaml b/packages/url_launcher/url_launcher_windows/pubspec.yaml index 4d330dd826d5..6435eda4564a 100644 --- a/packages/url_launcher/url_launcher_windows/pubspec.yaml +++ b/packages/url_launcher/url_launcher_windows/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_windows description: Windows implementation of the url_launcher plugin. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_windows issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" From 9bca9e7c8435a8a9dd321975f795e205aaa11502 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 3 Aug 2021 10:22:46 -0700 Subject: [PATCH 174/364] [google_maps_flutter] Temporarily disable googleMapsPluginIsAdded (#4214) Currently has an out-of-band failure on master. Ignoring to re-open the tree. --- .../io/flutter/plugins/googlemapsexample/GoogleMapsTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java index 40552ddf7be1..43ddeaae1579 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java @@ -8,9 +8,11 @@ import androidx.test.core.app.ActivityScenario; import io.flutter.plugins.googlemaps.GoogleMapsPlugin; +import org.junit.Ignore; import org.junit.Test; public class GoogleMapsTest { + @Ignore("Currently failing: https://github.com/flutter/flutter/issues/87566") @Test public void googleMapsPluginIsAdded() { final ActivityScenario scenario = From c59b32ce495ca3890e5250a884c14e8606b83599 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Tue, 3 Aug 2021 12:00:05 -0700 Subject: [PATCH 175/364] [google_sign_in] Mark iOS arm64 simulators as unsupported (#4208) --- packages/google_sign_in/google_sign_in/CHANGELOG.md | 4 ++++ .../google_sign_in/google_sign_in/ios/google_sign_in.podspec | 4 +++- packages/google_sign_in/google_sign_in/pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 2602e98be2a0..e4207de117fa 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.0.7 + +* Mark iOS arm64 simulators as unsupported. + ## 5.0.6 * Remove references to the Android V1 embedding. diff --git a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec index 6b0741c65122..a0b73276fafa 100644 --- a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec +++ b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec @@ -20,5 +20,7 @@ Enables Google Sign-In in Flutter apps. s.static_framework = true s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + + # GoogleSignIn ~> 5.0 does not support arm64 simulators. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' } end diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index bbcdbc91d71e..7e3f221716a8 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.0.6 +version: 5.0.7 environment: sdk: ">=2.12.0 <3.0.0" From 1fc3d927cd44835fb2db2263e850fab8f2bb7661 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Tue, 3 Aug 2021 12:05:09 -0700 Subject: [PATCH 176/364] [google_maps_flutter] Mark iOS arm64 simulators as unsupported (#4209) --- packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md | 4 ++++ .../example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../google_maps_flutter/ios/google_maps_flutter.podspec | 3 ++- packages/google_maps_flutter/google_maps_flutter/pubspec.yaml | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 6ffec4e65cc4..3080d4a2d733 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.8 + +* Mark iOS arm64 simulators as unsupported. + ## 2.0.7 * Add iOS unit and UI integration test targets. diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/packages/google_maps_flutter/google_maps_flutter/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj index cfaff19656f2..fbb006aeded0 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -535,7 +535,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -585,7 +585,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec b/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec index 9a1f04d59759..292dda006fa4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec +++ b/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec @@ -20,5 +20,6 @@ Downloaded by pub (not CocoaPods). s.dependency 'GoogleMaps' s.static_framework = true s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + # GoogleMaps does not support arm64 simulators. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' } end diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index c784e9a37a94..f1dc21ae2600 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.0.7 +version: 2.0.8 environment: sdk: '>=2.12.0 <3.0.0' From 643c928c2f2ff52e05dd5c5e695ddf155e9ed170 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 3 Aug 2021 12:10:07 -0700 Subject: [PATCH 177/364] [image_picker] Check for failure in iOS metadata updates (#4215) --- .../image_picker/image_picker/CHANGELOG.md | 7 ++--- .../ios/RunnerTests/MetaDataUtilTests.m | 10 ++++++- .../ios/Classes/FLTImagePickerMetaDataUtil.h | 6 ++++- .../ios/Classes/FLTImagePickerMetaDataUtil.m | 27 +++++++++++++------ .../Classes/FLTImagePickerPhotoAssetUtil.m | 6 ++++- .../image_picker/image_picker/pubspec.yaml | 2 +- 6 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index e7048c371a95..bd0a7a06b4fc 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.8.3 * Move `ImagePickerFromLimitedGalleryUITests` to `RunnerUITests` target. +* Improved handling of bad image data when applying metadata changes on iOS. ## 0.8.2 @@ -53,8 +54,8 @@ see: [#84634](https://github.com/flutter/flutter/issues/84634). ## 0.8.0 * BREAKING CHANGE: Changed storage location for captured images and videos to internal cache on Android, -to comply with new Google Play storage requirements. This means developers are responsible for moving -the image or video to a different location in case more permanent storage is required. Other applications +to comply with new Google Play storage requirements. This means developers are responsible for moving +the image or video to a different location in case more permanent storage is required. Other applications will no longer be able to access images or videos captured unless they are moved to a publicly accessible location. * Updated Mockito to fix Android tests. diff --git a/packages/image_picker/image_picker/example/ios/RunnerTests/MetaDataUtilTests.m b/packages/image_picker/image_picker/example/ios/RunnerTests/MetaDataUtilTests.m index e1dbfad77b5d..54f9469f2053 100644 --- a/packages/image_picker/image_picker/example/ios/RunnerTests/MetaDataUtilTests.m +++ b/packages/image_picker/image_picker/example/ios/RunnerTests/MetaDataUtilTests.m @@ -60,7 +60,7 @@ - (void)testWriteMetaData { NSString *tmpFile = [NSString stringWithFormat:@"image_picker_test.jpg"]; NSString *tmpDirectory = NSTemporaryDirectory(); NSString *tmpPath = [tmpDirectory stringByAppendingPathComponent:tmpFile]; - NSData *newData = [FLTImagePickerMetaDataUtil updateMetaData:metaData toImage:dataJPG]; + NSData *newData = [FLTImagePickerMetaDataUtil imageFromImage:dataJPG withMetaData:metaData]; if ([[NSFileManager defaultManager] createFileAtPath:tmpPath contents:newData attributes:nil]) { NSData *savedTmpImageData = [NSData dataWithContentsOfFile:tmpPath]; NSDictionary *tmpMetaData = @@ -71,6 +71,14 @@ - (void)testWriteMetaData { } } +- (void)testUpdateMetaDataBadData { + NSData *imageData = [NSData data]; + + NSDictionary *metaData = [FLTImagePickerMetaDataUtil getMetaDataFromImageData:imageData]; + NSData *newData = [FLTImagePickerMetaDataUtil imageFromImage:imageData withMetaData:metaData]; + XCTAssertNil(newData); +} + - (void)testConvertImageToData { UIImage *imageJPG = [UIImage imageWithData:ImagePickerTestImages.JPGTestData]; NSData *convertedDataJPG = [FLTImagePickerMetaDataUtil convertImage:imageJPG diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.h b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.h index d5a20ffc6d2e..72a36a56d57d 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.h +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.h @@ -27,7 +27,11 @@ extern const FLTImagePickerMIMEType kFLTImagePickerMIMETypeDefault; + (NSDictionary *)getMetaDataFromImageData:(NSData *)imageData; -+ (NSData *)updateMetaData:(NSDictionary *)metaData toImage:(NSData *)imageData; +// Creates and returns data for a new image based on imageData, but with the +// given metadata. +// +// If creating a new image fails, returns nil. ++ (nullable NSData *)imageFromImage:(NSData *)imageData withMetaData:(NSDictionary *)metadata; // Converting UIImage to a NSData with the type proveide. // diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m index 1419584a4675..45bcaa7191f7 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerMetaDataUtil.m @@ -49,16 +49,27 @@ + (NSDictionary *)getMetaDataFromImageData:(NSData *)imageData { return metadata; } -+ (NSData *)updateMetaData:(NSDictionary *)metaData toImage:(NSData *)imageData { - NSMutableData *mutableData = [NSMutableData data]; - CGImageSourceRef cgImage = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL); - CGImageDestinationRef destination = CGImageDestinationCreateWithData( - (__bridge CFMutableDataRef)mutableData, CGImageSourceGetType(cgImage), 1, nil); - CGImageDestinationAddImageFromSource(destination, cgImage, 0, (__bridge CFDictionaryRef)metaData); ++ (NSData *)imageFromImage:(NSData *)imageData withMetaData:(NSDictionary *)metadata { + NSMutableData *targetData = [NSMutableData data]; + CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL); + if (source == NULL) { + return nil; + } + CGImageDestinationRef destination = NULL; + CFStringRef sourceType = CGImageSourceGetType(source); + if (sourceType != NULL) { + destination = + CGImageDestinationCreateWithData((__bridge CFMutableDataRef)targetData, sourceType, 1, nil); + } + if (destination == NULL) { + CFRelease(source); + return nil; + } + CGImageDestinationAddImageFromSource(destination, source, 0, (__bridge CFDictionaryRef)metadata); CGImageDestinationFinalize(destination); - CFRelease(cgImage); + CFRelease(source); CFRelease(destination); - return mutableData; + return targetData; } + (NSData *)convertImage:(UIImage *)image diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m index ab881790d5ab..4c705fe54350 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPhotoAssetUtil.m @@ -86,7 +86,11 @@ + (NSString *)saveImageWithMetaData:(NSDictionary *)metaData usingType:type quality:imageQuality]; if (metaData) { - data = [FLTImagePickerMetaDataUtil updateMetaData:metaData toImage:data]; + NSData *updatedData = [FLTImagePickerMetaDataUtil imageFromImage:data withMetaData:metaData]; + // If updating the metadata fails, just save the original. + if (updatedData) { + data = updatedData; + } } return [self createFile:data suffix:suffix]; diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index e5ecfeb22232..f56250f53715 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.2 +version: 0.8.3 environment: sdk: ">=2.12.0 <3.0.0" From 498df33f7bd613e7f0dba16ebeff42ff98ee61c5 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 3 Aug 2021 16:24:46 -0700 Subject: [PATCH 178/364] [flutter_plugin_tools] Track and log exclusions (#4205) Makes commands that use the package-looping base command track and report exclusions. This will make it much easier to debug/audit situations where tests aren't running when expected (e.g., when enabling a new type of test for a package that previously had to be explicitly excluded from that test to avoid failing for having no tests, but forgetting to remove the package from the exclusion list). Also fixes a latent issue with using different exclusion lists on different commands in a single CI task when using sharding could cause unexpected failures due to different sets of plugins being included for each step (e.g., build+drive with an exclude list on drive could potentially try to drive a plugin that hadn't been built in that shard) by sharding before filtering out excluded packages. Adds testing for sharding in general, as there was previously none. --- script/tool/CHANGELOG.md | 2 + script/tool/lib/src/analyze_command.dart | 6 +- .../src/common/package_looping_command.dart | 90 +++++-- .../tool/lib/src/common/plugin_command.dart | 147 +++++++---- .../src/create_all_plugins_app_command.dart | 9 +- script/tool/lib/src/list_command.dart | 15 +- .../common/package_looping_command_test.dart | 76 ++++++ .../tool/test/common/plugin_command_test.dart | 239 +++++++++++++++--- 8 files changed, 460 insertions(+), 124 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 7d1eac01b760..7f326ff3c8f7 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -21,6 +21,8 @@ `--no-integration`. - **Breaking change**: Replaced `java-test` with Android unit test support for the new `native-test` command. +- Commands that print a run summary at the end now track and log exclusions + similarly to skips for easier auditing. ## 0.4.1 diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 4fd15f027f50..2b728e2b9073 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_command.dart'; import 'package:platform/platform.dart'; import 'package:yaml/yaml.dart'; @@ -84,7 +85,10 @@ class AnalyzeCommand extends PackageLoopingCommand { /// Ensures that the dependent packages have been fetched for all packages /// (including their sub-packages) that will be analyzed. Future _runPackagesGetOnTargetPackages() async { - final List packageDirectories = await getPackages().toList(); + final List packageDirectories = + await getTargetPackagesAndSubpackages() + .map((PackageEnumerationEntry package) => package.directory) + .toList(); final Set packagePaths = packageDirectories.map((Directory dir) => dir.path).toSet(); packageDirectories.removeWhere((Directory directory) { diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 0bcde6d296d3..0e0976ecc6a7 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -22,6 +22,10 @@ enum RunState { /// The command was skipped for the package. skipped, + /// The command was skipped for the package because it was explicitly excluded + /// in the command arguments. + excluded, + /// The command failed for the package. failed, } @@ -35,6 +39,9 @@ class PackageResult { PackageResult.skip(String reason) : this._(RunState.skipped, [reason]); + /// A run that was excluded by the command invocation. + PackageResult.exclude() : this._(RunState.excluded); + /// A run that failed. /// /// If [errors] are provided, they will be listed in the summary, otherwise @@ -70,13 +77,14 @@ abstract class PackageLoopingCommand extends PluginCommand { processRunner: processRunner, platform: platform, gitDir: gitDir); /// Packages that had at least one [logWarning] call. - final Set _packagesWithWarnings = {}; + final Set _packagesWithWarnings = + {}; /// Number of warnings that happened outside of a [runForPackage] call. int _otherWarningCount = 0; /// The package currently being run by [runForPackage]. - Directory? _currentPackage; + PackageEnumerationEntry? _currentPackage; /// Called during [run] before any calls to [runForPackage]. This provides an /// opportunity to fail early if the command can't be run (e.g., because the @@ -215,15 +223,24 @@ abstract class PackageLoopingCommand extends PluginCommand { await initializeRun(); - final List packages = includeSubpackages - ? await getPackages().toList() - : await getPlugins().toList(); + final List packages = includeSubpackages + ? await getTargetPackagesAndSubpackages(filterExcluded: false).toList() + : await getTargetPackages(filterExcluded: false).toList(); - final Map results = {}; - for (final Directory package in packages) { + final Map results = + {}; + for (final PackageEnumerationEntry package in packages) { _currentPackage = package; _printPackageHeading(package); - final PackageResult result = await runForPackage(package); + + // Command implementations should never see excluded packages; they are + // included at this level only for logging. + if (package.excluded) { + results[package] = PackageResult.exclude(); + continue; + } + + final PackageResult result = await runForPackage(package.directory); if (result.state == RunState.skipped) { final String message = '${indentation}SKIPPING: ${result.details.first}'; @@ -266,8 +283,11 @@ abstract class PackageLoopingCommand extends PluginCommand { /// Something is always printed to make it easier to distinguish between /// a command running for a package and producing no output, and a command /// not having been run for a package. - void _printPackageHeading(Directory package) { - String heading = 'Running for ${getPackageDescription(package)}'; + void _printPackageHeading(PackageEnumerationEntry package) { + final String packageDisplayName = getPackageDescription(package.directory); + String heading = package.excluded + ? 'Not running for $packageDisplayName; excluded' + : 'Running for $packageDisplayName'; if (hasLongOutput) { heading = ''' @@ -275,24 +295,35 @@ abstract class PackageLoopingCommand extends PluginCommand { || $heading ============================================================ '''; - } else { + } else if (!package.excluded) { heading = '$heading...'; } - captureOutput ? print(heading) : print(Colorize(heading)..cyan()); + if (captureOutput) { + print(heading); + } else { + final Colorize colorizeHeading = Colorize(heading); + print(package.excluded + ? colorizeHeading.darkGray() + : colorizeHeading.cyan()); + } } /// Prints a summary of packges run, packages skipped, and warnings. - void _printRunSummary( - List packages, Map results) { - final Set skippedPackages = results.entries - .where((MapEntry entry) => + void _printRunSummary(List packages, + Map results) { + final Set skippedPackages = results.entries + .where((MapEntry entry) => entry.value.state == RunState.skipped) - .map((MapEntry entry) => entry.key) + .map((MapEntry entry) => + entry.key) .toSet(); - final int skipCount = skippedPackages.length; + final int skipCount = skippedPackages.length + + packages + .where((PackageEnumerationEntry package) => package.excluded) + .length; // Split the warnings into those from packages that ran, and those that // were skipped. - final Set _skippedPackagesWithWarnings = + final Set _skippedPackagesWithWarnings = _packagesWithWarnings.intersection(skippedPackages); final int skippedWarningCount = _skippedPackagesWithWarnings.length; final int runWarningCount = @@ -318,14 +349,17 @@ abstract class PackageLoopingCommand extends PluginCommand { /// Prints a one-line-per-package overview of the run results for each /// package. - void _printPerPackageRunOverview(List packages, - {required Set skipped}) { + void _printPerPackageRunOverview(List packages, + {required Set skipped}) { print('Run overview:'); - for (final Directory package in packages) { + for (final PackageEnumerationEntry package in packages) { final bool hadWarning = _packagesWithWarnings.contains(package); Styles style; String summary; - if (skipped.contains(package)) { + if (package.excluded) { + summary = 'excluded'; + style = Styles.DARK_GRAY; + } else if (skipped.contains(package)) { summary = 'skipped'; style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY; } else { @@ -339,17 +373,17 @@ abstract class PackageLoopingCommand extends PluginCommand { if (!captureOutput) { summary = (Colorize(summary)..apply(style)).toString(); } - print(' ${getPackageDescription(package)} - $summary'); + print(' ${getPackageDescription(package.directory)} - $summary'); } print(''); } /// Prints a summary of all of the failures from [results]. - void _printFailureSummary( - List packages, Map results) { + void _printFailureSummary(List packages, + Map results) { const String indentation = ' '; _printError(failureListHeader); - for (final Directory package in packages) { + for (final PackageEnumerationEntry package in packages) { final PackageResult result = results[package]!; if (result.state == RunState.failed) { final String errorIndentation = indentation * 2; @@ -359,7 +393,7 @@ abstract class PackageLoopingCommand extends PluginCommand { ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; } _printError( - '$indentation${getPackageDescription(package)}$errorDetails'); + '$indentation${getPackageDescription(package.directory)}$errorDetails'); } } _printError(failureListFooter); diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 7781eee0d961..db0a821fd2d7 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -15,6 +15,19 @@ import 'core.dart'; import 'git_version_finder.dart'; import 'process_runner.dart'; +/// An entry in package enumeration for APIs that need to include extra +/// data about the entry. +class PackageEnumerationEntry { + /// Creates a new entry for the given package directory. + PackageEnumerationEntry(this.directory, {required this.excluded}); + + /// The package's location. + final Directory directory; + + /// Whether or not this package was excluded by the command invocation. + final bool excluded; +} + /// Interface definition for all commands in this tool. // TODO(stuartmorgan): Move most of this logic to PackageLoopingCommand. abstract class PluginCommand extends Command { @@ -97,6 +110,9 @@ abstract class PluginCommand extends Command { int? _shardIndex; int? _shardCount; + // Cached set of explicitly excluded packages. + Set? _excludedPackages; + /// A context that matches the default for [platform]. p.Context get path => platform.isWindows ? p.windows : p.posix; @@ -174,60 +190,82 @@ abstract class PluginCommand extends Command { _shardCount = shardCount; } - /// Returns the root Dart package folders of the plugins involved in this - /// command execution. - // TODO(stuartmorgan): Rename/restructure this, _getAllPlugins, and - // getPackages, as the current naming is very confusing. - Stream getPlugins() async* { + /// Returns the set of plugins to exclude based on the `--exclude` argument. + Set _getExcludedPackageName() { + final Set excludedPackages = _excludedPackages ?? + getStringListArg(_excludeArg).expand((String item) { + if (item.endsWith('.yaml')) { + final File file = packagesDir.fileSystem.file(item); + return (loadYaml(file.readAsStringSync()) as YamlList) + .toList() + .cast(); + } + return [item]; + }).toSet(); + // Cache for future calls. + _excludedPackages = excludedPackages; + return excludedPackages; + } + + /// Returns the root diretories of the packages involved in this command + /// execution. + /// + /// Depending on the command arguments, this may be a user-specified set of + /// packages, the set of packages that should be run for a given diff, or all + /// packages. + /// + /// By default, packages excluded via --exclude will not be in the stream, but + /// they can be included by passing false for [filterExcluded]. + Stream getTargetPackages( + {bool filterExcluded = true}) async* { // To avoid assuming consistency of `Directory.list` across command // invocations, we collect and sort the plugin folders before sharding. // This is considered an implementation detail which is why the API still // uses streams. - final List allPlugins = await _getAllPlugins().toList(); - allPlugins.sort((Directory d1, Directory d2) => d1.path.compareTo(d2.path)); - // Sharding 10 elements into 3 shards should yield shard sizes 4, 4, 2. - // Sharding 9 elements into 3 shards should yield shard sizes 3, 3, 3. - // Sharding 2 elements into 3 shards should yield shard sizes 1, 1, 0. + final List allPlugins = + await _getAllPackages().toList(); + allPlugins.sort((PackageEnumerationEntry p1, PackageEnumerationEntry p2) => + p1.directory.path.compareTo(p2.directory.path)); final int shardSize = allPlugins.length ~/ shardCount + (allPlugins.length % shardCount == 0 ? 0 : 1); final int start = min(shardIndex * shardSize, allPlugins.length); final int end = min(start + shardSize, allPlugins.length); - for (final Directory plugin in allPlugins.sublist(start, end)) { - yield plugin; + for (final PackageEnumerationEntry plugin + in allPlugins.sublist(start, end)) { + if (!(filterExcluded && plugin.excluded)) { + yield plugin; + } } } - /// Returns the root Dart package folders of the plugins involved in this - /// command execution, assuming there is only one shard. + /// Returns the root Dart package folders of the packages involved in this + /// command execution, assuming there is only one shard. Depending on the + /// command arguments, this may be a user-specified set of packages, the + /// set of packages that should be run for a given diff, or all packages. + /// + /// This will return packages that have been excluded by the --exclude + /// parameter, annotated in the entry as excluded. /// - /// Plugin packages can exist in the following places relative to the packages + /// Packages can exist in the following places relative to the packages /// directory: /// /// 1. As a Dart package in a directory which is a direct child of the - /// packages directory. This is a plugin where all of the implementations - /// exist in a single Dart package. + /// packages directory. This is a non-plugin package, or a non-federated + /// plugin. /// 2. Several plugin packages may live in a directory which is a direct /// child of the packages directory. This directory groups several Dart - /// packages which implement a single plugin. This directory contains a - /// "client library" package, which declares the API for the plugin, as - /// well as one or more platform-specific implementations. + /// packages which implement a single plugin. This directory contains an + /// "app-facing" package which declares the API for the plugin, a + /// platform interface package which declares the API for implementations, + /// and one or more platform-specific implementation packages. /// 3./4. Either of the above, but in a third_party/packages/ directory that /// is a sibling of the packages directory. This is used for a small number /// of packages in the flutter/packages repository. - Stream _getAllPlugins() async* { + Stream _getAllPackages() async* { Set plugins = Set.from(getStringListArg(_packagesArg)); - final Set excludedPlugins = - getStringListArg(_excludeArg).expand((String item) { - if (item.endsWith('.yaml')) { - final File file = packagesDir.fileSystem.file(item); - return (loadYaml(file.readAsStringSync()) as YamlList) - .toList() - .cast(); - } - return [item]; - }).toSet(); + final Set excludedPluginNames = _getExcludedPackageName(); final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); if (plugins.isEmpty && @@ -248,9 +286,9 @@ abstract class PluginCommand extends Command { in dir.list(followLinks: false)) { // A top-level Dart package is a plugin package. if (_isDartPackage(entity)) { - if (!excludedPlugins.contains(entity.basename) && - (plugins.isEmpty || plugins.contains(p.basename(entity.path)))) { - yield entity as Directory; + if (plugins.isEmpty || plugins.contains(p.basename(entity.path))) { + yield PackageEnumerationEntry(entity as Directory, + excluded: excludedPluginNames.contains(entity.basename)); } } else if (entity is Directory) { // Look for Dart packages under this top-level directory. @@ -264,13 +302,13 @@ abstract class PluginCommand extends Command { path.relative(subdir.path, from: dir.path); final String packageName = path.basename(subdir.path); final String basenamePath = path.basename(entity.path); - if (!excludedPlugins.contains(basenamePath) && - !excludedPlugins.contains(packageName) && - !excludedPlugins.contains(relativePath) && - (plugins.isEmpty || - plugins.contains(relativePath) || - plugins.contains(basenamePath))) { - yield subdir as Directory; + if (plugins.isEmpty || + plugins.contains(relativePath) || + plugins.contains(basenamePath)) { + yield PackageEnumerationEntry(subdir as Directory, + excluded: excludedPluginNames.contains(basenamePath) || + excludedPluginNames.contains(packageName) || + excludedPluginNames.contains(relativePath)); } } } @@ -279,27 +317,30 @@ abstract class PluginCommand extends Command { } } - /// Returns the example Dart package folders of the plugins involved in this - /// command execution. - Stream getExamples() => - getPlugins().expand(getExamplesForPlugin); - - /// Returns all Dart package folders (typically, plugin + example) of the - /// plugins involved in this command execution. - Stream getPackages() async* { - await for (final Directory plugin in getPlugins()) { + /// Returns all Dart package folders (typically, base package + example) of + /// the packages involved in this command execution. + /// + /// By default, packages excluded via --exclude will not be in the stream, but + /// they can be included by passing false for [filterExcluded]. + Stream getTargetPackagesAndSubpackages( + {bool filterExcluded = true}) async* { + await for (final PackageEnumerationEntry plugin + in getTargetPackages(filterExcluded: filterExcluded)) { yield plugin; - yield* plugin + yield* plugin.directory .list(recursive: true, followLinks: false) .where(_isDartPackage) - .cast(); + .map((FileSystemEntity directory) => PackageEnumerationEntry( + directory as Directory, // _isDartPackage guarantees this works. + excluded: plugin.excluded)); } } /// Returns the files contained, recursively, within the plugins /// involved in this command execution. Stream getFiles() { - return getPlugins() + return getTargetPackages() + .map((PackageEnumerationEntry entry) => entry.directory) .asyncExpand((Directory folder) => getFilesForPackage(folder)); } diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index ed7014456086..d4eccb8a313e 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -156,13 +156,14 @@ class CreateAllPluginsAppCommand extends PluginCommand { final Map pathDependencies = {}; - await for (final Directory package in getPlugins()) { - final String pluginName = package.basename; - final File pubspecFile = package.childFile('pubspec.yaml'); + await for (final PackageEnumerationEntry package in getTargetPackages()) { + final Directory pluginDirectory = package.directory; + final String pluginName = pluginDirectory.basename; + final File pubspecFile = pluginDirectory.childFile('pubspec.yaml'); final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); if (pubspec.publishTo != 'none') { - pathDependencies[pluginName] = PathDependency(package.path); + pathDependencies[pluginName] = PathDependency(pluginDirectory.path); } } return pathDependencies; diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart index 20f01ff98f0e..29a8ceb12782 100644 --- a/script/tool/lib/src/list_command.dart +++ b/script/tool/lib/src/list_command.dart @@ -39,18 +39,23 @@ class ListCommand extends PluginCommand { Future run() async { switch (getStringArg(_type)) { case _plugin: - await for (final Directory package in getPlugins()) { - print(package.path); + await for (final PackageEnumerationEntry package + in getTargetPackages()) { + print(package.directory.path); } break; case _example: - await for (final Directory package in getExamples()) { + final Stream examples = getTargetPackages() + .map((PackageEnumerationEntry entry) => entry.directory) + .expand(getExamplesForPlugin); + await for (final Directory package in examples) { print(package.path); } break; case _package: - await for (final Directory package in getPackages()) { - print(package.path); + await for (final PackageEnumerationEntry package + in getTargetPackagesAndSubpackages()) { + print(package.directory.path); } break; case _file: diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index 542e91af6431..00e64ddc21fe 100644 --- a/script/tool/test/common/package_looping_command_test.dart +++ b/script/tool/test/common/package_looping_command_test.dart @@ -185,6 +185,28 @@ void main() { package.childDirectory('example').path, ])); }); + + test('excludes subpackages when main package is excluded', () async { + final Directory excluded = createFakePlugin('a_plugin', packagesDir, + examples: ['example1', 'example2']); + final Directory included = createFakePackage('a_package', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(includeSubpackages: true); + await runCommand(command, arguments: ['--exclude=a_plugin']); + + expect( + command.checkedPackages, + unorderedEquals([ + included.path, + included.childDirectory('example').path, + ])); + expect(command.checkedPackages, isNot(contains(excluded.path))); + expect(command.checkedPackages, + isNot(contains(excluded.childDirectory('example1').path))); + expect(command.checkedPackages, + isNot(contains(excluded.childDirectory('example2').path))); + }); }); group('output', () { @@ -376,6 +398,23 @@ void main() { ])); }); + test('logs exclusions', () async { + createFakePackage('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = + await runCommand(command, arguments: ['--exclude=package_b']); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startSkipColor}Not running for package_b; excluded$_endColor', + ])); + }); + test('logs warnings', () async { final Directory warnPackage = createFakePackage('package_a', packagesDir); warnPackage @@ -435,6 +474,24 @@ void main() { expect(output, isNot(contains(contains('package a - ran')))); }); + test('counts exclusions as skips in run summary', () async { + createFakePackage('package_a', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = + await runCommand(command, arguments: ['--exclude=package_a']); + + expect( + output, + containsAllInOrder([ + '------------------------------------------------------------', + 'Skipped 1 package(s)', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + }); + test('prints long-form run summary for long-output commands', () async { final Directory warnPackage1 = createFakePackage('package_a', packagesDir); @@ -478,6 +535,25 @@ void main() { ])); }); + test('prints exclusions as skips in long-form run summary', () async { + createFakePackage('package_a', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true); + final List output = + await runCommand(command, arguments: ['--exclude=package_a']); + + expect( + output, + containsAllInOrder([ + ' package_a - ${_startSkipColor}excluded$_endColor', + '', + 'Skipped 1 package(s)', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + }); + test('handles warnings outside of runForPackage', () async { createFakePackage('package_a', packagesDir); diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 7f67acfb2df3..2f332aa8eb55 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -22,12 +22,12 @@ import 'plugin_command_test.mocks.dart'; @GenerateMocks([GitDir]) void main() { late RecordingProcessRunner processRunner; + late SamplePluginCommand command; late CommandRunner runner; late FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; late Directory thirdPartyPackagesDir; - late List plugins; late List?> gitDirCommands; late String gitDiffResponse; @@ -53,9 +53,7 @@ void main() { return Future.value(mockProcessResult); }); processRunner = RecordingProcessRunner(); - plugins = []; - final SamplePluginCommand samplePluginCommand = SamplePluginCommand( - plugins, + command = SamplePluginCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, @@ -63,7 +61,7 @@ void main() { ); runner = CommandRunner('common_command', 'Test for common functionality'); - runner.addCommand(samplePluginCommand); + runner.addCommand(command); }); group('plugin iteration', () { @@ -71,7 +69,8 @@ void main() { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, ['sample']); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('includes both plugins and packages', () async { @@ -81,7 +80,7 @@ void main() { final Directory package4 = createFakePackage('package4', packagesDir); await runCapturingPrint(runner, ['sample']); expect( - plugins, + command.plugins, unorderedEquals([ plugin1.path, plugin2.path, @@ -96,7 +95,7 @@ void main() { final Directory plugin3 = createFakePlugin('plugin3', thirdPartyPackagesDir); await runCapturingPrint(runner, ['sample']); - expect(plugins, + expect(command.plugins, unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); }); @@ -108,7 +107,7 @@ void main() { await runCapturingPrint( runner, ['sample', '--packages=plugin1,package4']); expect( - plugins, + command.plugins, unorderedEquals([ plugin1.path, package4.path, @@ -123,7 +122,7 @@ void main() { await runCapturingPrint( runner, ['sample', '--plugins=plugin1,package4']); expect( - plugins, + command.plugins, unorderedEquals([ plugin1.path, package4.path, @@ -138,7 +137,7 @@ void main() { '--packages=plugin1,plugin2', '--exclude=plugin1' ]); - expect(plugins, unorderedEquals([plugin2.path])); + expect(command.plugins, unorderedEquals([plugin2.path])); }); test('exclude packages when packages flag isn\'t specified', () async { @@ -146,7 +145,7 @@ void main() { createFakePlugin('plugin2', packagesDir); await runCapturingPrint( runner, ['sample', '--exclude=plugin1,plugin2']); - expect(plugins, unorderedEquals([])); + expect(command.plugins, unorderedEquals([])); }); test('exclude federated plugins when packages flag is specified', () async { @@ -157,7 +156,7 @@ void main() { '--packages=federated/plugin1,plugin2', '--exclude=federated/plugin1' ]); - expect(plugins, unorderedEquals([plugin2.path])); + expect(command.plugins, unorderedEquals([plugin2.path])); }); test('exclude entire federated plugins when packages flag is specified', @@ -169,7 +168,7 @@ void main() { '--packages=federated/plugin1,plugin2', '--exclude=federated' ]); - expect(plugins, unorderedEquals([plugin2.path])); + expect(command.plugins, unorderedEquals([plugin2.path])); }); test('exclude accepts config files', () async { @@ -182,7 +181,7 @@ void main() { '--packages=plugin1', '--exclude=${configFile.path}' ]); - expect(plugins, unorderedEquals([])); + expect(command.plugins, unorderedEquals([])); }); group('test run-on-changed-packages', () { @@ -195,7 +194,8 @@ void main() { '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test( @@ -210,7 +210,8 @@ void main() { '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if .cirrus.yml changes.', () async { @@ -226,7 +227,8 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if .ci.yaml changes', () async { @@ -242,7 +244,8 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if anything in .ci/ changes', @@ -259,7 +262,8 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if anything in script changes.', @@ -276,7 +280,8 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if the root analysis options change.', @@ -293,7 +298,8 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('all plugins should be tested if formatting options change.', @@ -310,7 +316,8 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('Only changed plugin should be tested.', () async { @@ -323,7 +330,7 @@ packages/plugin1/CHANGELOG '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path])); + expect(command.plugins, unorderedEquals([plugin1.path])); }); test('multiple files in one plugin should also test the plugin', @@ -340,7 +347,7 @@ packages/plugin1/ios/plugin1.m '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path])); + expect(command.plugins, unorderedEquals([plugin1.path])); }); test('multiple plugins changed should test all the changed plugins', @@ -358,7 +365,8 @@ packages/plugin2/ios/plugin2.m '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test( @@ -379,7 +387,31 @@ packages/plugin1/plugin1_web/plugin1_web.dart '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path])); + expect(command.plugins, unorderedEquals([plugin1.path])); + }); + + test( + 'changing one plugin in a federated group should include all plugins in the group', + () async { + gitDiffResponse = ''' +packages/plugin1/plugin1/plugin1.dart +'''; + final Directory plugin1 = + createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); + final Directory plugin2 = createFakePlugin('plugin1_platform_interface', + packagesDir.childDirectory('plugin1')); + final Directory plugin3 = createFakePlugin( + 'plugin1_web', packagesDir.childDirectory('plugin1')); + await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect( + command.plugins, + unorderedEquals( + [plugin1.path, plugin2.path, plugin3.path])); }); test( @@ -401,7 +433,8 @@ packages/plugin3/plugin3.dart '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); }); test('--exclude flag works with --run-on-changed-packages', () async { @@ -421,15 +454,155 @@ packages/plugin3/plugin3.dart '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path])); + expect(command.plugins, unorderedEquals([plugin1.path])); }); }); }); + + group('sharding', () { + test('distributes evenly when evenly divisible', () async { + final List> expectedShards = >[ + [ + createFakePackage('package1', packagesDir), + createFakePackage('package2', packagesDir), + createFakePackage('package3', packagesDir), + ], + [ + createFakePackage('package4', packagesDir), + createFakePackage('package5', packagesDir), + createFakePackage('package6', packagesDir), + ], + [ + createFakePackage('package7', packagesDir), + createFakePackage('package8', packagesDir), + createFakePackage('package9', packagesDir), + ], + ]; + + for (int i = 0; i < expectedShards.length; ++i) { + final SamplePluginCommand localCommand = SamplePluginCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + gitDir: MockGitDir(), + ); + final CommandRunner localRunner = + CommandRunner('common_command', 'Shard testing'); + localRunner.addCommand(localCommand); + + await runCapturingPrint(localRunner, [ + 'sample', + '--shardIndex=$i', + '--shardCount=3', + ]); + expect( + localCommand.plugins, + unorderedEquals(expectedShards[i] + .map((Directory package) => package.path) + .toList())); + } + }); + + test('distributes as evenly as possible when not evenly divisible', + () async { + final List> expectedShards = >[ + [ + createFakePackage('package1', packagesDir), + createFakePackage('package2', packagesDir), + createFakePackage('package3', packagesDir), + ], + [ + createFakePackage('package4', packagesDir), + createFakePackage('package5', packagesDir), + createFakePackage('package6', packagesDir), + ], + [ + createFakePackage('package7', packagesDir), + createFakePackage('package8', packagesDir), + ], + ]; + + for (int i = 0; i < expectedShards.length; ++i) { + final SamplePluginCommand localCommand = SamplePluginCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + gitDir: MockGitDir(), + ); + final CommandRunner localRunner = + CommandRunner('common_command', 'Shard testing'); + localRunner.addCommand(localCommand); + + await runCapturingPrint(localRunner, [ + 'sample', + '--shardIndex=$i', + '--shardCount=3', + ]); + expect( + localCommand.plugins, + unorderedEquals(expectedShards[i] + .map((Directory package) => package.path) + .toList())); + } + }); + + // In CI (which is the use case for sharding) we often want to run muliple + // commands on the same set of packages, but the exclusion lists for those + // commands may be different. In those cases we still want all the commands + // to operate on a consistent set of plugins. + // + // E.g., some commands require running build-examples in a previous step; + // excluding some plugins from the later step shouldn't change what's tested + // in each shard, as it may no longer align with what was built. + test('counts excluded plugins when sharding', () async { + final List> expectedShards = >[ + [ + createFakePackage('package1', packagesDir), + createFakePackage('package2', packagesDir), + createFakePackage('package3', packagesDir), + ], + [ + createFakePackage('package4', packagesDir), + createFakePackage('package5', packagesDir), + createFakePackage('package6', packagesDir), + ], + [ + createFakePackage('package7', packagesDir), + ], + ]; + // These would be in the last shard, but are excluded. + createFakePackage('package8', packagesDir); + createFakePackage('package9', packagesDir); + + for (int i = 0; i < expectedShards.length; ++i) { + final SamplePluginCommand localCommand = SamplePluginCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + gitDir: MockGitDir(), + ); + final CommandRunner localRunner = + CommandRunner('common_command', 'Shard testing'); + localRunner.addCommand(localCommand); + + await runCapturingPrint(localRunner, [ + 'sample', + '--shardIndex=$i', + '--shardCount=3', + '--exclude=package8,package9', + ]); + expect( + localCommand.plugins, + unorderedEquals(expectedShards[i] + .map((Directory package) => package.path) + .toList())); + } + }); + }); } class SamplePluginCommand extends PluginCommand { SamplePluginCommand( - this._plugins, Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), @@ -437,7 +610,7 @@ class SamplePluginCommand extends PluginCommand { }) : super(packagesDir, processRunner: processRunner, platform: platform, gitDir: gitDir); - final List _plugins; + final List plugins = []; @override final String name = 'sample'; @@ -447,8 +620,8 @@ class SamplePluginCommand extends PluginCommand { @override Future run() async { - await for (final Directory package in getPlugins()) { - _plugins.add(package.path); + await for (final PackageEnumerationEntry package in getTargetPackages()) { + plugins.add(package.directory.path); } } } From 60100908443fffee28df82735a9c4019cf549227 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 4 Aug 2021 06:45:01 -0700 Subject: [PATCH 179/364] Don't use 'flutter upgrade' on Cirrus (#4213) This command isn't intended for CI use, and is also slower due to downloading artifacts that will be immidately discarded. Cirrus portion of https://github.com/flutter/flutter/issues/86037 --- .cirrus.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index 5e8425fc2437..f978cc729799 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -20,8 +20,12 @@ flutter_upgrade_template: &FLUTTER_UPGRADE_TEMPLATE - git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" - git fetch origin # Switch to the requested branch. - - flutter channel $CHANNEL - - flutter upgrade + - git checkout $CHANNEL + # Reset to upstream branch, rather than using pull, since the base image + # can sometimes be in a state where it has diverged from upstream (!). + - git reset --hard @{u} + # Run doctor to allow auditing of what version of Flutter the run is using. + - flutter doctor -v << : *TOOL_SETUP_TEMPLATE macos_template: &MACOS_TEMPLATE From 083b45e19d3bfbfbfca0b6059aed6c1124019854 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Fri, 6 Aug 2021 20:35:04 +0300 Subject: [PATCH 180/364] [image_picker] Fix README example (#4220) --- packages/image_picker/image_picker/CHANGELOG.md | 4 ++++ packages/image_picker/image_picker/README.md | 4 +--- packages/image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index bd0a7a06b4fc..f9c7640183d5 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.3+1 + +* Fixed README Example. + ## 0.8.3 * Move `ImagePickerFromLimitedGalleryUITests` to `RunnerUITests` target. diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index 18fd96d890fd..7499c356f3aa 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -47,9 +47,7 @@ import 'package:image_picker/image_picker.dart'; // Capture a video final XFile? photo = await _picker.pickVideo(source: ImageSource.camera); // Pick multiple images - final List? images = await _picker.pickMultiImage(source: ImageSource.gallery); - // Pick multiple photos - final List? photos = await _picker.pickMultiImage(source: ImageSource.camera); + final List? images = await _picker.pickMultiImage(); ... ``` diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index f56250f53715..e67e79fbba14 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.3 +version: 0.8.3+1 environment: sdk: ">=2.12.0 <3.0.0" From c70465665790a885d4cdbd4b8bc4bd6544bc6d3c Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Sat, 7 Aug 2021 03:55:04 +0200 Subject: [PATCH 181/364] [camera_web] Add `onCameraResolutionChanged` implementation (#4217) --- .../example/integration_test/camera_web_test.dart | 6 +++--- packages/camera/camera_web/lib/src/camera_web.dart | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 1b540a50e48d..083a25dd06bb 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -1068,11 +1068,11 @@ void main() { await streamQueue.cancel(); }); - testWidgets('onCameraResolutionChanged throws UnimplementedError', + testWidgets('onCameraResolutionChanged emits an empty stream', (tester) async { expect( - () => CameraPlatform.instance.onCameraResolutionChanged(cameraId), - throwsUnimplementedError, + CameraPlatform.instance.onCameraResolutionChanged(cameraId), + emits(isEmpty), ); }); diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 35241d0c9b8b..dbfbcacd3ce0 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -302,9 +302,14 @@ class CameraPlugin extends CameraPlatform { return _cameraEvents(cameraId).whereType(); } + /// Emits an empty stream as there is no event corresponding to a change + /// in the camera resolution on the web. + /// + /// In order to change the camera resolution a new camera with appropriate + /// [CameraOptions.video] constraints has to be created and initialized. @override Stream onCameraResolutionChanged(int cameraId) { - throw UnimplementedError('onCameraResolutionChanged() is not implemented.'); + return const Stream.empty(); } @override From acc3202466a8b4c0b1bcb91fc1ce520c7b021e44 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Sat, 7 Aug 2021 04:00:05 +0200 Subject: [PATCH 182/364] [camera_web] Add support for device orientation (#4219) --- .../camera_settings_test.dart | 114 ++++++ .../integration_test/camera_web_test.dart | 349 ++++++++++++++++-- .../integration_test/helpers/mocks.dart | 8 + .../camera_web/lib/src/camera_settings.dart | 34 ++ .../camera/camera_web/lib/src/camera_web.dart | 63 +++- .../lib/src/types/camera_error_code.dart | 4 + .../lib/src/types/orientation_type.dart | 26 ++ .../camera_web/lib/src/types/types.dart | 2 +- .../test/types/camera_error_code_test.dart | 7 + 9 files changed, 576 insertions(+), 31 deletions(-) create mode 100644 packages/camera/camera_web/lib/src/types/orientation_type.dart diff --git a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart index 7e5119003129..bc228b2e35c6 100644 --- a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart @@ -607,6 +607,120 @@ void main() { ); }); }); + + group('mapDeviceOrientationToOrientationType', () { + testWidgets( + 'returns portraitPrimary ' + 'when the device orientation is portraitUp', (tester) async { + expect( + settings.mapDeviceOrientationToOrientationType( + DeviceOrientation.portraitUp, + ), + equals(OrientationType.portraitPrimary), + ); + }); + + testWidgets( + 'returns landscapePrimary ' + 'when the device orientation is landscapeLeft', (tester) async { + expect( + settings.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeLeft, + ), + equals(OrientationType.landscapePrimary), + ); + }); + + testWidgets( + 'returns portraitSecondary ' + 'when the device orientation is portraitDown', (tester) async { + expect( + settings.mapDeviceOrientationToOrientationType( + DeviceOrientation.portraitDown, + ), + equals(OrientationType.portraitSecondary), + ); + }); + + testWidgets( + 'returns landscapeSecondary ' + 'when the device orientation is landscapeRight', (tester) async { + expect( + settings.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeRight, + ), + equals(OrientationType.landscapeSecondary), + ); + }); + }); + + group('mapOrientationTypeToDeviceOrientation', () { + testWidgets( + 'returns portraitUp ' + 'when the orientation type is portraitPrimary', (tester) async { + expect( + settings.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitPrimary, + ), + equals(DeviceOrientation.portraitUp), + ); + }); + + testWidgets( + 'returns landscapeLeft ' + 'when the orientation type is landscapePrimary', (tester) async { + expect( + settings.mapOrientationTypeToDeviceOrientation( + OrientationType.landscapePrimary, + ), + equals(DeviceOrientation.landscapeLeft), + ); + }); + + testWidgets( + 'returns portraitDown ' + 'when the orientation type is portraitSecondary', (tester) async { + expect( + settings.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitSecondary, + ), + equals(DeviceOrientation.portraitDown), + ); + }); + + testWidgets( + 'returns portraitDown ' + 'when the orientation type is portraitSecondary', (tester) async { + expect( + settings.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitSecondary, + ), + equals(DeviceOrientation.portraitDown), + ); + }); + + testWidgets( + 'returns landscapeRight ' + 'when the orientation type is landscapeSecondary', (tester) async { + expect( + settings.mapOrientationTypeToDeviceOrientation( + OrientationType.landscapeSecondary, + ), + equals(DeviceOrientation.landscapeRight), + ); + }); + + testWidgets( + 'returns portraitUp ' + 'for an unknown orientation type', (tester) async { + expect( + settings.mapOrientationTypeToDeviceOrientation( + 'unknown', + ), + equals(DeviceOrientation.portraitUp), + ); + }); + }); }); } diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 083a25dd06bb..e11634d83fce 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -30,6 +30,11 @@ void main() { late Navigator navigator; late MediaDevices mediaDevices; late VideoElement videoElement; + late Screen screen; + late ScreenOrientation screenOrientation; + late Document document; + late Element documentElement; + late CameraSettings cameraSettings; setUp(() async { @@ -39,11 +44,23 @@ void main() { videoElement = getVideoElementWithBlankStream(Size(10, 10)); - cameraSettings = MockCameraSettings(); - when(() => window.navigator).thenReturn(navigator); when(() => navigator.mediaDevices).thenReturn(mediaDevices); + screen = MockScreen(); + screenOrientation = MockScreenOrientation(); + + when(() => screen.orientation).thenReturn(screenOrientation); + when(() => window.screen).thenReturn(screen); + + document = MockDocument(); + documentElement = MockElement(); + + when(() => document.documentElement).thenReturn(documentElement); + when(() => window.document).thenReturn(document); + + cameraSettings = MockCameraSettings(); + when( () => cameraSettings.getMediaStreamForOptions( any(), @@ -636,23 +653,236 @@ void main() { }); }); - testWidgets('lockCaptureOrientation throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.lockCaptureOrientation( + group('lockCaptureOrientation', () { + setUp(() { + when( + () => cameraSettings.mapDeviceOrientationToOrientationType(any()), + ).thenReturn(OrientationType.portraitPrimary); + }); + + testWidgets( + 'requests full-screen mode ' + 'on documentElement', (tester) async { + await CameraPlatform.instance.lockCaptureOrientation( cameraId, - DeviceOrientation.landscapeLeft, - ), - throwsUnimplementedError, - ); + DeviceOrientation.portraitUp, + ); + + verify(documentElement.requestFullscreen).called(1); + }); + + testWidgets( + 'locks the capture orientation ' + 'based on the given device orientation', (tester) async { + when( + () => cameraSettings.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeRight, + ), + ).thenReturn(OrientationType.landscapeSecondary); + + await CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.landscapeRight, + ); + + verify( + () => cameraSettings.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeRight, + ), + ).called(1); + + verify( + () => screenOrientation.lock( + OrientationType.landscapeSecondary, + ), + ).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with orientationNotSupported error ' + 'when screen is not supported', (tester) async { + when(() => window.screen).thenReturn(null); + + expect( + () => CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.portraitUp, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.orientationNotSupported.toString(), + ), + ), + ); + }); + + testWidgets( + 'with orientationNotSupported error ' + 'when screen orientation is not supported', (tester) async { + when(() => screen.orientation).thenReturn(null); + + expect( + () => CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.portraitUp, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.orientationNotSupported.toString(), + ), + ), + ); + }); + + testWidgets( + 'with orientationNotSupported error ' + 'when documentElement is not available', (tester) async { + when(() => document.documentElement).thenReturn(null); + + expect( + () => CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.portraitUp, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.orientationNotSupported.toString(), + ), + ), + ); + }); + + testWidgets('when lock throws DomException', (tester) async { + final exception = FakeDomException(DomException.NOT_ALLOWED); + + when(() => screenOrientation.lock(any())).thenThrow(exception); + + expect( + () => CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.portraitDown, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + }); }); - testWidgets('unlockCaptureOrientation throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.unlockCaptureOrientation(cameraId), - throwsUnimplementedError, - ); + group('unlockCaptureOrientation', () { + setUp(() { + when( + () => cameraSettings.mapDeviceOrientationToOrientationType(any()), + ).thenReturn(OrientationType.portraitPrimary); + }); + + testWidgets( + 'requests full-screen mode ' + 'on documentElement', (tester) async { + await CameraPlatform.instance.unlockCaptureOrientation( + cameraId, + ); + + verify(documentElement.requestFullscreen).called(1); + }); + + testWidgets('unlocks the capture orientation', (tester) async { + await CameraPlatform.instance.unlockCaptureOrientation( + cameraId, + ); + + verify(screenOrientation.unlock).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with orientationNotSupported error ' + 'when screen is not supported', (tester) async { + when(() => window.screen).thenReturn(null); + + expect( + () => CameraPlatform.instance.unlockCaptureOrientation( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.orientationNotSupported.toString(), + ), + ), + ); + }); + + testWidgets( + 'with orientationNotSupported error ' + 'when screen orientation is not supported', (tester) async { + when(() => screen.orientation).thenReturn(null); + + expect( + () => CameraPlatform.instance.unlockCaptureOrientation( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.orientationNotSupported.toString(), + ), + ), + ); + }); + + testWidgets( + 'with orientationNotSupported error ' + 'when documentElement is not available', (tester) async { + when(() => document.documentElement).thenReturn(null); + + expect( + () => CameraPlatform.instance.unlockCaptureOrientation( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.orientationNotSupported.toString(), + ), + ), + ); + }); + + testWidgets('when unlock throws DomException', (tester) async { + final exception = FakeDomException(DomException.NOT_ALLOWED); + + when(screenOrientation.unlock).thenThrow(exception); + + expect( + () => CameraPlatform.instance.unlockCaptureOrientation( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + }); }); group('takePicture', () { @@ -1213,12 +1443,87 @@ void main() { ); }); - testWidgets('onDeviceOrientationChanged throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.onDeviceOrientationChanged(), - throwsUnimplementedError, - ); + group('onDeviceOrientationChanged', () { + group('emits an empty stream', () { + testWidgets('when screen is not supported', (tester) async { + when(() => window.screen).thenReturn(null); + + expect( + CameraPlatform.instance.onDeviceOrientationChanged(), + emits(isEmpty), + ); + }); + + testWidgets('when screen orientation is not supported', + (tester) async { + when(() => screen.orientation).thenReturn(null); + + expect( + CameraPlatform.instance.onDeviceOrientationChanged(), + emits(isEmpty), + ); + }); + }); + + testWidgets( + 'emits a DeviceOrientationChangedEvent ' + 'when the screen orientation is changed', (tester) async { + when( + () => cameraSettings.mapOrientationTypeToDeviceOrientation( + OrientationType.landscapePrimary, + ), + ).thenReturn(DeviceOrientation.landscapeLeft); + + when( + () => cameraSettings.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitSecondary, + ), + ).thenReturn(DeviceOrientation.portraitDown); + + final eventStreamController = StreamController(); + + when(() => screenOrientation.onChange) + .thenAnswer((_) => eventStreamController.stream); + + final Stream eventStream = + CameraPlatform.instance.onDeviceOrientationChanged(); + + final streamQueue = StreamQueue(eventStream); + + // Change the screen orientation to landscapePrimary and + // emit an event on the screenOrientation.onChange stream. + when(() => screenOrientation.type) + .thenReturn(OrientationType.landscapePrimary); + + eventStreamController.add(Event('orientationChanged')); + + expect( + await streamQueue.next, + equals( + DeviceOrientationChangedEvent( + DeviceOrientation.landscapeLeft, + ), + ), + ); + + // Change the screen orientation to portraitSecondary and + // emit an event on the screenOrientation.onChange stream. + when(() => screenOrientation.type) + .thenReturn(OrientationType.portraitSecondary); + + eventStreamController.add(Event('orientationChanged')); + + expect( + await streamQueue.next, + equals( + DeviceOrientationChangedEvent( + DeviceOrientation.portraitDown, + ), + ), + ); + + await streamQueue.cancel(); + }); }); }); }); diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 8af3a9c3cd81..5fa52dd3398d 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -14,6 +14,14 @@ import 'package:mocktail/mocktail.dart'; class MockWindow extends Mock implements Window {} +class MockScreen extends Mock implements Screen {} + +class MockScreenOrientation extends Mock implements ScreenOrientation {} + +class MockDocument extends Mock implements Document {} + +class MockElement extends Mock implements Element {} + class MockNavigator extends Mock implements Navigator {} class MockMediaDevices extends Mock implements MediaDevices {} diff --git a/packages/camera/camera_web/lib/src/camera_settings.dart b/packages/camera/camera_web/lib/src/camera_settings.dart index 1412248a2371..ce713bc52468 100644 --- a/packages/camera/camera_web/lib/src/camera_settings.dart +++ b/packages/camera/camera_web/lib/src/camera_settings.dart @@ -230,4 +230,38 @@ class CameraSettings { return Size(320, 240); } } + + /// Maps the given [deviceOrientation] to [OrientationType]. + String mapDeviceOrientationToOrientationType( + DeviceOrientation deviceOrientation, + ) { + switch (deviceOrientation) { + case DeviceOrientation.portraitUp: + return OrientationType.portraitPrimary; + case DeviceOrientation.landscapeLeft: + return OrientationType.landscapePrimary; + case DeviceOrientation.portraitDown: + return OrientationType.portraitSecondary; + case DeviceOrientation.landscapeRight: + return OrientationType.landscapeSecondary; + } + } + + /// Maps the given [orientationType] to [DeviceOrientation]. + DeviceOrientation mapOrientationTypeToDeviceOrientation( + String orientationType, + ) { + switch (orientationType) { + case OrientationType.portraitPrimary: + return DeviceOrientation.portraitUp; + case OrientationType.landscapePrimary: + return DeviceOrientation.landscapeLeft; + case OrientationType.portraitSecondary: + return DeviceOrientation.portraitDown; + case OrientationType.landscapeSecondary: + return DeviceOrientation.landscapeRight; + default: + return DeviceOrientation.portraitUp; + } + } } diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index dbfbcacd3ce0..01fc0a23aa34 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -329,22 +329,69 @@ class CameraPlugin extends CameraPlatform { @override Stream onDeviceOrientationChanged() { - throw UnimplementedError( - 'onDeviceOrientationChanged() is not implemented.', - ); + final orientation = window?.screen?.orientation; + + if (orientation != null) { + return orientation.onChange.map( + (html.Event _) { + final deviceOrientation = _cameraSettings + .mapOrientationTypeToDeviceOrientation(orientation.type!); + return DeviceOrientationChangedEvent(deviceOrientation); + }, + ); + } else { + return const Stream.empty(); + } } @override Future lockCaptureOrientation( int cameraId, - DeviceOrientation orientation, - ) { - throw UnimplementedError('lockCaptureOrientation() is not implemented.'); + DeviceOrientation deviceOrientation, + ) async { + try { + final orientation = window?.screen?.orientation; + final documentElement = window?.document.documentElement; + + if (orientation != null && documentElement != null) { + final orientationType = _cameraSettings + .mapDeviceOrientationToOrientationType(deviceOrientation); + + // Full-screen mode may be required to modify the device orientation. + // See: https://w3c.github.io/screen-orientation/#interaction-with-fullscreen-api + documentElement.requestFullscreen(); + await orientation.lock(orientationType.toString()); + } else { + throw PlatformException( + code: CameraErrorCode.orientationNotSupported.toString(), + message: 'Orientation is not supported in the current browser.', + ); + } + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } } @override - Future unlockCaptureOrientation(int cameraId) { - throw UnimplementedError('unlockCaptureOrientation() is not implemented.'); + Future unlockCaptureOrientation(int cameraId) async { + try { + final orientation = window?.screen?.orientation; + final documentElement = window?.document.documentElement; + + if (orientation != null && documentElement != null) { + // Full-screen mode may be required to modify the device orientation. + // See: https://w3c.github.io/screen-orientation/#interaction-with-fullscreen-api + documentElement.requestFullscreen(); + orientation.unlock(); + } else { + throw PlatformException( + code: CameraErrorCode.orientationNotSupported.toString(), + message: 'Orientation is not supported in the current browser.', + ); + } + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } } @override diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart index 3dcace3ca2d6..9a70663c4aaf 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_code.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -48,6 +48,10 @@ class CameraErrorCode { static const CameraErrorCode missingMetadata = CameraErrorCode._('cameraMissingMetadata'); + /// The camera orientation is not supported. + static const CameraErrorCode orientationNotSupported = + CameraErrorCode._('orientationNotSupported'); + /// An unknown camera error. static const CameraErrorCode unknown = CameraErrorCode._('cameraUnknown'); diff --git a/packages/camera/camera_web/lib/src/types/orientation_type.dart b/packages/camera/camera_web/lib/src/types/orientation_type.dart new file mode 100644 index 000000000000..717f5f399541 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/orientation_type.dart @@ -0,0 +1,26 @@ +// Copyright 2013 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 'package:flutter/services.dart'; + +/// A screen orientation type. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/type +abstract class OrientationType { + /// The primary portrait mode orientation. + /// Corresponds to [DeviceOrientation.portraitUp]. + static const String portraitPrimary = 'portrait-primary'; + + /// The secondary portrait mode orientation. + /// Corresponds to [DeviceOrientation.portraitSecondary]. + static const String portraitSecondary = 'portrait-secondary'; + + /// The primary landscape mode orientation. + /// Corresponds to [DeviceOrientation.landscapeLeft]. + static const String landscapePrimary = 'landscape-primary'; + + /// The secondary landscape mode orientation. + /// Corresponds to [DeviceOrientation.landscapeRight]. + static const String landscapeSecondary = 'landscape-secondary'; +} diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart index 788ec79de205..4e3902fcb3ee 100644 --- a/packages/camera/camera_web/lib/src/types/types.dart +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -1,9 +1,9 @@ // Copyright 2013 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. - export 'camera_error_code.dart'; export 'camera_metadata.dart'; export 'camera_options.dart'; export 'camera_web_exception.dart'; export 'media_device_kind.dart'; +export 'orientation_type.dart'; diff --git a/packages/camera/camera_web/test/types/camera_error_code_test.dart b/packages/camera/camera_web/test/types/camera_error_code_test.dart index ca896e8696d7..6f2d7dd1cd09 100644 --- a/packages/camera/camera_web/test/types/camera_error_code_test.dart +++ b/packages/camera/camera_web/test/types/camera_error_code_test.dart @@ -75,6 +75,13 @@ void main() { ); }); + test('orientationNotSupported', () { + expect( + CameraErrorCode.orientationNotSupported.toString(), + equals('orientationNotSupported'), + ); + }); + test('unknown', () { expect( CameraErrorCode.unknown.toString(), From b9512b627343f58ab543396b7bfed27a8add5a4e Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Mon, 9 Aug 2021 19:20:05 +0200 Subject: [PATCH 183/364] [camera_web] Add support for a flash mode (#4222) --- .../example/integration_test/camera_test.dart | 396 +++++++++++++++++- .../integration_test/camera_web_test.dart | 127 +++++- .../camera/camera_web/lib/src/camera.dart | 98 ++++- .../camera/camera_web/lib/src/camera_web.dart | 11 +- .../lib/src/types/camera_error_code.dart | 8 + .../test/types/camera_error_code_test.dart | 14 + 6 files changed, 619 insertions(+), 35 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 49690ed38ab5..740e24f87819 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -5,6 +5,7 @@ import 'dart:html'; import 'dart:ui'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; @@ -18,10 +19,23 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Camera', () { + const textureId = 1; + + late Window window; + late Navigator navigator; + late MediaDevices mediaDevices; + late MediaStream mediaStream; late CameraSettings cameraSettings; setUp(() { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + cameraSettings = MockCameraSettings(); final videoElement = getVideoElementWithBlankStream(Size(10, 10)); @@ -51,7 +65,7 @@ void main() { ); final camera = Camera( - textureId: 1, + textureId: textureId, options: options, cameraSettings: cameraSettings, ); @@ -61,7 +75,7 @@ void main() { verify( () => cameraSettings.getMediaStreamForOptions( options, - cameraId: 1, + cameraId: textureId, ), ).called(1); }); @@ -72,7 +86,7 @@ void main() { const audioConstraints = AudioConstraints(enabled: true); final camera = Camera( - textureId: 1, + textureId: textureId, options: CameraOptions( audio: audioConstraints, ), @@ -100,7 +114,7 @@ void main() { 'creates a wrapping div element ' 'with correct properties', (tester) async { final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -111,6 +125,17 @@ void main() { expect(camera.divElement.children, contains(camera.videoElement)); }); + testWidgets('initializes the camera stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ); + + await camera.initialize(); + + expect(camera.stream, mediaStream); + }); + testWidgets( 'throws an exception ' 'when CameraSettings.getMediaStreamForOptions throws', @@ -121,7 +146,7 @@ void main() { cameraId: any(named: 'cameraId'))).thenThrow(exception); final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -137,7 +162,7 @@ void main() { var startedPlaying = false; final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -154,9 +179,8 @@ void main() { }); testWidgets( - 'assigns a media stream ' + 'initializes the camera stream ' 'from CameraSettings.getMediaStreamForOptions ' - 'to the video element\'s source ' 'if it does not exist', (tester) async { final options = CameraOptions( video: VideoConstraints( @@ -165,7 +189,7 @@ void main() { ); final camera = Camera( - textureId: 1, + textureId: textureId, options: options, cameraSettings: cameraSettings, ); @@ -182,18 +206,19 @@ void main() { verify( () => cameraSettings.getMediaStreamForOptions( options, - cameraId: 1, + cameraId: textureId, ), ).called(2); expect(camera.videoElement.srcObject, mediaStream); + expect(camera.stream, mediaStream); }); }); group('stop', () { - testWidgets('resets the video element\'s source', (tester) async { + testWidgets('resets the camera stream', (tester) async { final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -203,13 +228,14 @@ void main() { camera.stop(); expect(camera.videoElement.srcObject, isNull); + expect(camera.stream, isNull); }); }); group('takePicture', () { testWidgets('returns a captured picture', (tester) async { final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -220,6 +246,99 @@ void main() { expect(pictureFile, isNotNull); }); + + group( + 'enables the torch mode ' + 'when taking a picture', () { + late List videoTracks; + late MediaStream videoStream; + late VideoElement videoElement; + + setUp(() { + videoTracks = [MockMediaStreamTrack(), MockMediaStreamTrack()]; + videoStream = FakeMediaStream(videoTracks); + + videoElement = getVideoElementWithBlankStream(Size(100, 100)) + ..muted = true; + + when(() => videoTracks.first.applyConstraints(any())) + .thenAnswer((_) async => {}); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': true, + }); + }); + + testWidgets('if the flash mode is auto', (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream + ..videoElement = videoElement + ..flashMode = FlashMode.auto; + + await camera.play(); + + final _ = await camera.takePicture(); + + verify( + () => videoTracks.first.applyConstraints({ + "advanced": [ + { + "torch": true, + } + ] + }), + ).called(1); + + verify( + () => videoTracks.first.applyConstraints({ + "advanced": [ + { + "torch": false, + } + ] + }), + ).called(1); + }); + + testWidgets('if the flash mode is always', (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream + ..videoElement = videoElement + ..flashMode = FlashMode.always; + + await camera.play(); + + final _ = await camera.takePicture(); + + verify( + () => videoTracks.first.applyConstraints({ + "advanced": [ + { + "torch": true, + } + ] + }), + ).called(1); + + verify( + () => videoTracks.first.applyConstraints({ + "advanced": [ + { + "torch": false, + } + ] + }), + ).called(1); + }); + }); }); group('getVideoSize', () { @@ -232,7 +351,7 @@ void main() { mediaStream = videoElement.captureStream(); final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -252,7 +371,7 @@ void main() { mediaStream = videoElement.captureStream(); final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); @@ -265,10 +384,251 @@ void main() { }); }); + group('setFlashMode', () { + late List videoTracks; + late MediaStream videoStream; + + setUp(() { + videoTracks = [MockMediaStreamTrack(), MockMediaStreamTrack()]; + videoStream = FakeMediaStream(videoTracks); + + when(() => videoTracks.first.applyConstraints(any())) + .thenAnswer((_) async => {}); + + when(videoTracks.first.getCapabilities).thenReturn({}); + }); + + testWidgets('sets the camera flash mode', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'torch': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': true, + }); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream; + + const flashMode = FlashMode.always; + + camera.setFlashMode(flashMode); + + expect( + camera.flashMode, + equals(flashMode), + ); + }); + + testWidgets( + 'enables the torch mode ' + 'if the flash mode is torch', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'torch': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': true, + }); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream; + + camera.setFlashMode(FlashMode.torch); + + verify( + () => videoTracks.first.applyConstraints({ + "advanced": [ + { + "torch": true, + } + ] + }), + ).called(1); + }); + + testWidgets( + 'disables the torch mode ' + 'if the flash mode is not torch', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'torch': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': true, + }); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream; + + camera.setFlashMode(FlashMode.auto); + + verify( + () => videoTracks.first.applyConstraints({ + "advanced": [ + { + "torch": false, + } + ] + }), + ).called(1); + }); + + group('throws CameraWebException', () { + testWidgets( + 'with torchModeNotSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream; + + expect( + () => camera.setFlashMode(FlashMode.always), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.torchModeNotSupported, + ), + ), + ); + }); + + testWidgets( + 'with torchModeNotSupported error ' + 'when the torch mode is not supported ' + 'in the browser', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'torch': false, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': true, + }); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream; + + expect( + () => camera.setFlashMode(FlashMode.always), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.torchModeNotSupported, + ), + ), + ); + }); + + testWidgets( + 'with torchModeNotSupported error ' + 'when the torch mode is not supported ' + 'by the camera', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'torch': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': false, + }); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ) + ..window = window + ..stream = videoStream; + + expect( + () => camera.setFlashMode(FlashMode.always), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.torchModeNotSupported, + ), + ), + ); + }); + + testWidgets( + 'with notStarted error ' + 'when the camera stream has not been initialized', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'torch': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'torch': true, + }); + + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + )..window = window; + + expect( + () => camera.setFlashMode(FlashMode.always), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notStarted, + ), + ), + ); + }); + }); + }); + group('getViewType', () { testWidgets('returns a correct view type', (tester) async { - const textureId = 1; - final camera = Camera( textureId: textureId, cameraSettings: cameraSettings, @@ -286,7 +646,7 @@ void main() { group('dispose', () { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( - textureId: 1, + textureId: textureId, cameraSettings: cameraSettings, ); diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index e11634d83fce..fda35dd088c1 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -78,6 +78,7 @@ void main() { setUpAll(() { registerFallbackValue(MockMediaStreamTrack()); registerFallbackValue(MockCameraOptions()); + registerFallbackValue(FlashMode.off); }); testWidgets('CameraPlugin is the live instance', (tester) async { @@ -981,14 +982,79 @@ void main() { ); }); - testWidgets('setFlashMode throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.setFlashMode( - cameraId, - FlashMode.auto, - ), - throwsUnimplementedError, - ); + group('setFlashMode', () { + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.setFlashMode( + cameraId, + FlashMode.always, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when setFlashMode throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(() => camera.setFlashMode(any())).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.setFlashMode( + cameraId, + FlashMode.always, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when setFlashMode throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(() => camera.setFlashMode(any())).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.setFlashMode( + cameraId, + FlashMode.torch, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); testWidgets('setExposureMode throws UnimplementedError', (tester) async { @@ -1345,7 +1411,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on video error ' + 'on initialize video error ' 'with a message', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1380,7 +1446,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on video error ' + 'on initialize video error ' 'with no message', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1411,7 +1477,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on abort error', (tester) async { + 'on initialize abort error', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1433,6 +1499,45 @@ void main() { await streamQueue.cancel(); }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on setFlashMode error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(() => camera.setFlashMode(any())).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => await CameraPlatform.instance.setFlashMode( + cameraId, + FlashMode.always, + ), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); }); testWidgets('onVideoRecordedEvent throws UnimplementedError', diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 9e469033dfc4..1cd007b917bb 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -8,6 +8,7 @@ import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; +import 'package:flutter/foundation.dart'; import 'shims/dart_ui.dart' as ui; @@ -39,6 +40,10 @@ class Camera { this.options = const CameraOptions(), }) : _cameraSettings = cameraSettings; + // A torch mode constraint name. + // See: https://w3c.github.io/mediacapture-image/#dom-mediatracksupportedconstraints-torch + static const _torchModeKey = "torch"; + /// The texture id used to register the camera view. final int textureId; @@ -47,20 +52,32 @@ class Camera { /// The video element that displays the camera stream. /// Initialized in [initialize]. - late html.VideoElement videoElement; + late final html.VideoElement videoElement; /// The wrapping element for the [videoElement] to avoid overriding /// the custom styles applied in [_applyDefaultVideoStyles]. /// Initialized in [initialize]. - late html.DivElement divElement; + late final html.DivElement divElement; + + /// The camera stream displayed in the [videoElement]. + /// Initialized in [initialize] and [play], reset in [stop]. + html.MediaStream? stream; + + /// The camera flash mode. + @visibleForTesting + FlashMode? flashMode; /// The camera settings used to get the media stream for the camera. final CameraSettings _cameraSettings; + /// The current browser window used to access media devices. + @visibleForTesting + html.Window? window = html.window; + /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. Future initialize() async { - final stream = await _cameraSettings.getMediaStreamForOptions( + stream = await _cameraSettings.getMediaStreamForOptions( options, cameraId: textureId, ); @@ -89,7 +106,7 @@ class Camera { /// Initializes the camera source if the camera was previously stopped. Future play() async { if (videoElement.srcObject == null) { - final stream = await _cameraSettings.getMediaStreamForOptions( + stream = await _cameraSettings.getMediaStreamForOptions( options, cameraId: textureId, ); @@ -107,18 +124,36 @@ class Camera { } } videoElement.srcObject = null; + stream = null; } /// Captures a picture and returns the saved file in a JPEG format. + /// + /// Enables the device flash when taking a picture if the flash mode + /// is either [FlashMode.auto] or [FlashMode.always]. Future takePicture() async { + final shouldEnableTorchMode = + flashMode == FlashMode.auto || flashMode == FlashMode.always; + + if (shouldEnableTorchMode) { + _setTorchMode(enabled: true); + } + final videoWidth = videoElement.videoWidth; final videoHeight = videoElement.videoHeight; final canvas = html.CanvasElement(width: videoWidth, height: videoHeight); + canvas.context2D ..translate(videoWidth, 0) ..scale(-1, 1) ..drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); + final blob = await canvas.toBlob('image/jpeg'); + + if (shouldEnableTorchMode) { + _setTorchMode(enabled: false); + } + return XFile(html.Url.createObjectUrl(blob)); } @@ -146,6 +181,61 @@ class Camera { } } + /// Sets the camera flash mode to [mode]. + void setFlashMode(FlashMode mode) { + final mediaDevices = window?.navigator.mediaDevices; + final supportedConstraints = mediaDevices?.getSupportedConstraints(); + final torchModeSupported = supportedConstraints?[_torchModeKey] ?? false; + + if (!torchModeSupported) { + throw CameraWebException( + textureId, + CameraErrorCode.torchModeNotSupported, + 'The torch mode is not supported in the current browser.', + ); + } + + // Save the updated flash mode to be used later when taking a picture. + flashMode = mode; + + // Enable the torch mode only if the flash mode is torch. + _setTorchMode(enabled: mode == FlashMode.torch); + } + + /// Sets the camera torch mode constraint to [enabled]. + void _setTorchMode({required bool enabled}) { + final videoTracks = stream?.getVideoTracks() ?? []; + + if (videoTracks.isNotEmpty) { + final defaultVideoTrack = videoTracks.first; + + final bool canEnableTorchMode = + defaultVideoTrack.getCapabilities()[_torchModeKey] ?? false; + + if (canEnableTorchMode) { + defaultVideoTrack.applyConstraints({ + "advanced": [ + { + _torchModeKey: enabled, + } + ] + }); + } else { + throw CameraWebException( + textureId, + CameraErrorCode.torchModeNotSupported, + 'The torch mode is not supported by the current camera.', + ); + } + } else { + throw CameraWebException( + textureId, + CameraErrorCode.notStarted, + 'The camera has not been initialized or started.', + ); + } + } + /// Returns the registered view type of the camera. String getViewType() => _getViewType(textureId); diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 01fc0a23aa34..0ae0d9e75c24 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -429,8 +429,15 @@ class CameraPlugin extends CameraPlatform { } @override - Future setFlashMode(int cameraId, FlashMode mode) { - throw UnimplementedError('setFlashMode() is not implemented.'); + Future setFlashMode(int cameraId, FlashMode mode) async { + try { + getCamera(cameraId).setFlashMode(mode); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart index 9a70663c4aaf..904920db6ac6 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_code.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -52,6 +52,14 @@ class CameraErrorCode { static const CameraErrorCode orientationNotSupported = CameraErrorCode._('orientationNotSupported'); + /// The camera torch mode is not supported. + static const CameraErrorCode torchModeNotSupported = + CameraErrorCode._('torchModeNotSupported'); + + /// The camera has not been initialized or started. + static const CameraErrorCode notStarted = + CameraErrorCode._('cameraNotStarted'); + /// An unknown camera error. static const CameraErrorCode unknown = CameraErrorCode._('cameraUnknown'); diff --git a/packages/camera/camera_web/test/types/camera_error_code_test.dart b/packages/camera/camera_web/test/types/camera_error_code_test.dart index 6f2d7dd1cd09..1fec82b16f8d 100644 --- a/packages/camera/camera_web/test/types/camera_error_code_test.dart +++ b/packages/camera/camera_web/test/types/camera_error_code_test.dart @@ -82,6 +82,20 @@ void main() { ); }); + test('torchModeNotSupported', () { + expect( + CameraErrorCode.torchModeNotSupported.toString(), + equals('torchModeNotSupported'), + ); + }); + + test('notStarted', () { + expect( + CameraErrorCode.notStarted.toString(), + equals('cameraNotStarted'), + ); + }); + test('unknown', () { expect( CameraErrorCode.unknown.toString(), From 19f2ff71a8ba86b4cf4a52e9d18b4c650dcb9285 Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Mon, 9 Aug 2021 12:40:05 -0700 Subject: [PATCH 184/364] Add `buildViewWithTextDirection` to platform interface (#4121) --- .../CHANGELOG.md | 4 + .../method_channel_google_maps_flutter.dart | 160 ++++++++---------- .../google_maps_flutter_platform.dart | 38 ++++- .../pubspec.yaml | 2 +- .../google_maps_flutter_platform_test.dart | 40 +++++ 5 files changed, 157 insertions(+), 87 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md index 2dc533fe1dfa..5d361d8e0c7c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.1 + +* Method `buildViewWithTextDirection` has been added to the platform interface. + ## 2.1.0 * Add support for Hybrid Composition when building the Google Maps widget on Android. Set diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart index 41aedc759b15..2b9c71ee85bd 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart @@ -456,11 +456,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { /// Defaults to false. bool useAndroidViewSurface = false; - /// Returns a widget displaying the map view. - /// - /// This method includes a parameter for platforms that require a text - /// direction. For example, this should be used when using hybrid composition - /// on Android. + @override Widget buildViewWithTextDirection( int creationId, PlatformViewCreatedCallback onPlatformViewCreated, { @@ -473,79 +469,6 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { Set tileOverlays = const {}, Set>? gestureRecognizers, Map mapOptions = const {}, - }) { - if (defaultTargetPlatform == TargetPlatform.android && - useAndroidViewSurface) { - final Map creationParams = { - 'initialCameraPosition': initialCameraPosition.toMap(), - 'options': mapOptions, - 'markersToAdd': serializeMarkerSet(markers), - 'polygonsToAdd': serializePolygonSet(polygons), - 'polylinesToAdd': serializePolylineSet(polylines), - 'circlesToAdd': serializeCircleSet(circles), - 'tileOverlaysToAdd': serializeTileOverlaySet(tileOverlays), - }; - return PlatformViewLink( - viewType: 'plugins.flutter.io/google_maps', - surfaceFactory: ( - BuildContext context, - PlatformViewController controller, - ) { - return AndroidViewSurface( - controller: controller as AndroidViewController, - gestureRecognizers: gestureRecognizers ?? - const >{}, - hitTestBehavior: PlatformViewHitTestBehavior.opaque, - ); - }, - onCreatePlatformView: (PlatformViewCreationParams params) { - final SurfaceAndroidViewController controller = - PlatformViewsService.initSurfaceAndroidView( - id: params.id, - viewType: 'plugins.flutter.io/google_maps', - layoutDirection: textDirection, - creationParams: creationParams, - creationParamsCodec: const StandardMessageCodec(), - onFocus: () => params.onFocusChanged(true), - ); - controller.addOnPlatformViewCreatedListener( - params.onPlatformViewCreated, - ); - controller.addOnPlatformViewCreatedListener( - onPlatformViewCreated, - ); - - controller.create(); - return controller; - }, - ); - } - return buildView( - creationId, - onPlatformViewCreated, - initialCameraPosition: initialCameraPosition, - markers: markers, - polygons: polygons, - polylines: polylines, - circles: circles, - tileOverlays: tileOverlays, - gestureRecognizers: gestureRecognizers, - mapOptions: mapOptions, - ); - } - - @override - Widget buildView( - int creationId, - PlatformViewCreatedCallback onPlatformViewCreated, { - required CameraPosition initialCameraPosition, - Set markers = const {}, - Set polygons = const {}, - Set polylines = const {}, - Set circles = const {}, - Set tileOverlays = const {}, - Set>? gestureRecognizers, - Map mapOptions = const {}, }) { final Map creationParams = { 'initialCameraPosition': initialCameraPosition.toMap(), @@ -556,14 +479,52 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { 'circlesToAdd': serializeCircleSet(circles), 'tileOverlaysToAdd': serializeTileOverlaySet(tileOverlays), }; + if (defaultTargetPlatform == TargetPlatform.android) { - return AndroidView( - viewType: 'plugins.flutter.io/google_maps', - onPlatformViewCreated: onPlatformViewCreated, - gestureRecognizers: gestureRecognizers, - creationParams: creationParams, - creationParamsCodec: const StandardMessageCodec(), - ); + if (useAndroidViewSurface) { + return PlatformViewLink( + viewType: 'plugins.flutter.io/google_maps', + surfaceFactory: ( + BuildContext context, + PlatformViewController controller, + ) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: gestureRecognizers ?? + const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (PlatformViewCreationParams params) { + final SurfaceAndroidViewController controller = + PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: 'plugins.flutter.io/google_maps', + layoutDirection: textDirection, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ); + controller.addOnPlatformViewCreatedListener( + params.onPlatformViewCreated, + ); + controller.addOnPlatformViewCreatedListener( + onPlatformViewCreated, + ); + + controller.create(); + return controller; + }, + ); + } else { + return AndroidView( + viewType: 'plugins.flutter.io/google_maps', + onPlatformViewCreated: onPlatformViewCreated, + gestureRecognizers: gestureRecognizers, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + ); + } } else if (defaultTargetPlatform == TargetPlatform.iOS) { return UiKitView( viewType: 'plugins.flutter.io/google_maps', @@ -573,7 +534,36 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { creationParamsCodec: const StandardMessageCodec(), ); } + return Text( '$defaultTargetPlatform is not yet supported by the maps plugin'); } + + @override + Widget buildView( + int creationId, + PlatformViewCreatedCallback onPlatformViewCreated, { + required CameraPosition initialCameraPosition, + Set markers = const {}, + Set polygons = const {}, + Set polylines = const {}, + Set circles = const {}, + Set tileOverlays = const {}, + Set>? gestureRecognizers, + Map mapOptions = const {}, + }) { + return buildViewWithTextDirection( + creationId, + onPlatformViewCreated, + initialCameraPosition: initialCameraPosition, + textDirection: TextDirection.ltr, + markers: markers, + polygons: polygons, + polylines: polylines, + circles: circles, + tileOverlays: tileOverlays, + gestureRecognizers: gestureRecognizers, + mapOptions: mapOptions, + ); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart index 425e040ee812..2bb0ab2588f9 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart @@ -338,7 +338,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('dispose() has not been implemented.'); } - /// Returns a widget displaying the map view + /// Returns a widget displaying the map view. Widget buildView( int creationId, PlatformViewCreatedCallback onPlatformViewCreated, { @@ -356,4 +356,40 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { }) { throw UnimplementedError('buildView() has not been implemented.'); } + + /// Returns a widget displaying the map view. + /// + /// This method is similar to [buildView], but contains a parameter for + /// platforms that require a text direction. + /// + /// Default behavior passes all parameters except `textDirection` to + /// [buildView]. This is for backward compatibility with existing + /// implementations. Platforms that use the text direction should override + /// this as the primary implementation, and delegate to it from buildView. + Widget buildViewWithTextDirection( + int creationId, + PlatformViewCreatedCallback onPlatformViewCreated, { + required CameraPosition initialCameraPosition, + required TextDirection textDirection, + Set markers = const {}, + Set polygons = const {}, + Set polylines = const {}, + Set circles = const {}, + Set tileOverlays = const {}, + Set>? gestureRecognizers, + Map mapOptions = const {}, + }) { + return buildView( + creationId, + onPlatformViewCreated, + initialCameraPosition: initialCameraPosition, + markers: markers, + polygons: polygons, + polylines: polylines, + circles: circles, + tileOverlays: tileOverlays, + gestureRecognizers: gestureRecognizers, + mapOptions: mapOptions, + ); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml index 1ea425ea0273..1dc73f442d2e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.1.0 +version: 2.1.1 environment: sdk: '>=2.12.0 <3.0.0' diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart index 2c50313ab8a6..de4edf375696 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart @@ -2,6 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; import 'package:mockito/mockito.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; @@ -34,6 +38,23 @@ void main() { test('Can be extended', () { GoogleMapsFlutterPlatform.instance = ExtendsGoogleMapsFlutterPlatform(); }); + + test( + 'default implementation of `buildViewWithTextDirection` delegates to `buildView`', + () { + final GoogleMapsFlutterPlatform platform = + BuildViewGoogleMapsFlutterPlatform(); + expect( + platform.buildViewWithTextDirection( + 0, + (_) {}, + initialCameraPosition: CameraPosition(target: LatLng(0.0, 0.0)), + textDirection: TextDirection.ltr, + ), + isA(), + ); + }, + ); }); } @@ -45,3 +66,22 @@ class ImplementsGoogleMapsFlutterPlatform extends Mock implements GoogleMapsFlutterPlatform {} class ExtendsGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform {} + +class BuildViewGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { + @override + Widget buildView( + int creationId, + PlatformViewCreatedCallback onPlatformViewCreated, { + required CameraPosition initialCameraPosition, + Set markers = const {}, + Set polygons = const {}, + Set polylines = const {}, + Set circles = const {}, + Set tileOverlays = const {}, + Set>? gestureRecognizers = + const >{}, + Map mapOptions = const {}, + }) { + return const Text(''); + } +} From 256d37dd2198b71edeeb4e6fe42ec657045c1516 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 10 Aug 2021 00:05:07 +0200 Subject: [PATCH 185/364] [image_picker] fix camera on Android 11 (#3194) --- .../image_picker/image_picker/CHANGELOG.md | 5 ++ .../imagepicker/ImagePickerDelegate.java | 54 +++++++++---------- .../imagepicker/ImagePickerDelegateTest.java | 16 +++--- .../image_picker/image_picker/pubspec.yaml | 2 +- 4 files changed, 37 insertions(+), 40 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index f9c7640183d5..9d89389cb105 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.3+2 + +* Fix using Camera as image source on Android 11+ + ## 0.8.3+1 * Fixed README Example. @@ -26,6 +30,7 @@ * Fix image picker causing a crash when the cache directory is deleted. ## 0.8.1+2 + * Update the example app to support the multi-image feature. ## 0.8.1+1 diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index 8b904f5d769d..dbd0f70af936 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -6,6 +6,7 @@ import android.Manifest; import android.app.Activity; +import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; @@ -88,7 +89,6 @@ public class ImagePickerDelegate private final ImageResizer imageResizer; private final ImagePickerCache cache; private final PermissionManager permissionManager; - private final IntentResolver intentResolver; private final FileUriResolver fileUriResolver; private final FileUtils fileUtils; private CameraDevice cameraDevice; @@ -101,10 +101,6 @@ interface PermissionManager { boolean needRequestCameraPermission(); } - interface IntentResolver { - boolean resolveActivity(Intent intent); - } - interface FileUriResolver { Uri resolveFileProviderUriForFile(String fileProviderName, File imageFile); @@ -148,12 +144,6 @@ public boolean needRequestCameraPermission() { return ImagePickerUtils.needRequestCameraPermission(activity); } }, - new IntentResolver() { - @Override - public boolean resolveActivity(Intent intent) { - return intent.resolveActivity(activity.getPackageManager()) != null; - } - }, new FileUriResolver() { @Override public Uri resolveFileProviderUriForFile(String fileProviderName, File file) { @@ -190,7 +180,6 @@ public void onScanCompleted(String path, Uri uri) { final MethodCall methodCall, final ImagePickerCache cache, final PermissionManager permissionManager, - final IntentResolver intentResolver, final FileUriResolver fileUriResolver, final FileUtils fileUtils) { this.activity = activity; @@ -200,7 +189,6 @@ public void onScanCompleted(String path, Uri uri) { this.pendingResult = result; this.methodCall = methodCall; this.permissionManager = permissionManager; - this.intentResolver = intentResolver; this.fileUriResolver = fileUriResolver; this.fileUtils = fileUtils; this.cache = cache; @@ -291,13 +279,6 @@ private void launchTakeVideoWithCameraIntent() { useFrontCamera(intent); } - boolean canTakePhotos = intentResolver.resolveActivity(intent); - - if (!canTakePhotos) { - finishWithError("no_available_camera", "No cameras available for taking pictures."); - return; - } - File videoFile = createTemporaryWritableVideoFile(); pendingCameraMediaUri = Uri.parse("file:" + videoFile.getAbsolutePath()); @@ -305,7 +286,18 @@ private void launchTakeVideoWithCameraIntent() { intent.putExtra(MediaStore.EXTRA_OUTPUT, videoUri); grantUriPermissions(intent, videoUri); - activity.startActivityForResult(intent, REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA); + try { + activity.startActivityForResult(intent, REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA); + } catch (ActivityNotFoundException e) { + try { + // If we can't delete the file again here, there's not really anything we can do about it. + //noinspection ResultOfMethodCallIgnored + videoFile.delete(); + } catch (SecurityException exception) { + exception.printStackTrace(); + } + finishWithError("no_available_camera", "No cameras available for taking pictures."); + } } public void chooseImageFromGallery(MethodCall methodCall, MethodChannel.Result result) { @@ -371,13 +363,6 @@ private void launchTakeImageWithCameraIntent() { useFrontCamera(intent); } - boolean canTakePhotos = intentResolver.resolveActivity(intent); - - if (!canTakePhotos) { - finishWithError("no_available_camera", "No cameras available for taking pictures."); - return; - } - File imageFile = createTemporaryWritableImageFile(); pendingCameraMediaUri = Uri.parse("file:" + imageFile.getAbsolutePath()); @@ -385,7 +370,18 @@ private void launchTakeImageWithCameraIntent() { intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri); grantUriPermissions(intent, imageUri); - activity.startActivityForResult(intent, REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA); + try { + activity.startActivityForResult(intent, REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA); + } catch (ActivityNotFoundException e) { + try { + // If we can't delete the file again here, there's not really anything we can do about it. + //noinspection ResultOfMethodCallIgnored + imageFile.delete(); + } catch (SecurityException exception) { + exception.printStackTrace(); + } + finishWithError("no_available_camera", "No cameras available for taking pictures."); + } } private File createTemporaryWritableImageFile() { diff --git a/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index 1b55a7569eac..ebd58d05fee4 100644 --- a/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -7,7 +7,9 @@ import static org.hamcrest.core.IsEqual.equalTo; import static org.junit.Assert.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -16,6 +18,7 @@ import android.Manifest; import android.app.Activity; +import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; @@ -42,7 +45,6 @@ public class ImagePickerDelegateTest { @Mock MethodCall mockMethodCall; @Mock MethodChannel.Result mockResult; @Mock ImagePickerDelegate.PermissionManager mockPermissionManager; - @Mock ImagePickerDelegate.IntentResolver mockIntentResolver; @Mock FileUtils mockFileUtils; @Mock Intent mockIntent; @Mock ImagePickerCache cache; @@ -164,7 +166,6 @@ public void takeImageWithCamera_WhenHasNoCameraPermission_RequestsForPermission( @Test public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermission() { when(mockPermissionManager.needRequestCameraPermission()).thenReturn(false); - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); ImagePickerDelegate delegate = createDelegate(); delegate.takeImageWithCamera(mockMethodCall, mockResult); @@ -178,7 +179,6 @@ public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermis public void takeImageWithCamera_WhenHasCameraPermission_AndAnActivityCanHandleCameraIntent_LaunchesTakeWithCameraIntent() { when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); ImagePickerDelegate delegate = createDelegate(); delegate.takeImageWithCamera(mockMethodCall, mockResult); @@ -192,8 +192,9 @@ public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermis public void takeImageWithCamera_WhenHasCameraPermission_AndNoActivityToHandleCameraIntent_FinishesWithNoCamerasAvailableError() { when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(false); - + doThrow(ActivityNotFoundException.class) + .when(mockActivity) + .startActivityForResult(any(Intent.class), anyInt()); ImagePickerDelegate delegate = createDelegate(); delegate.takeImageWithCamera(mockMethodCall, mockResult); @@ -205,7 +206,6 @@ public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermis @Test public void takeImageWithCamera_WritesImageToCacheDirectory() { when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); ImagePickerDelegate delegate = createDelegate(); delegate.takeImageWithCamera(mockMethodCall, mockResult); @@ -231,7 +231,6 @@ public void onRequestPermissionsResult_WhenCameraPermissionDenied_FinishesWithEr @Test public void onRequestTakeVideoPermissionsResult_WhenCameraPermissionGranted_LaunchesTakeVideoWithCameraIntent() { - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); delegate.onRequestPermissionsResult( @@ -247,7 +246,6 @@ public void onRequestPermissionsResult_WhenCameraPermissionDenied_FinishesWithEr @Test public void onRequestTakeImagePermissionsResult_WhenCameraPermissionGranted_LaunchesTakeWithCameraIntent() { - when(mockIntentResolver.resolveActivity(any(Intent.class))).thenReturn(true); ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); delegate.onRequestPermissionsResult( @@ -379,7 +377,6 @@ private ImagePickerDelegate createDelegate() { null, cache, mockPermissionManager, - mockIntentResolver, mockFileUriResolver, mockFileUtils); } @@ -393,7 +390,6 @@ private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { mockMethodCall, cache, mockPermissionManager, - mockIntentResolver, mockFileUriResolver, mockFileUtils); } diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index e67e79fbba14..e167d8ab891c 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.3+1 +version: 0.8.3+2 environment: sdk: ">=2.12.0 <3.0.0" From d31bd7db62cb141ce98d2bf1286a606f017b607f Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Tue, 10 Aug 2021 11:57:06 -0700 Subject: [PATCH 186/364] [webview_flutter] Only call onWebResourceError for main frame (#3078) --- .../webview_flutter/CHANGELOG.md | 4 +- .../webviewflutter/FlutterWebViewClient.java | 17 ++++--- .../webview_flutter_test.dart | 44 +++++++++++++++++++ .../webview_flutter/lib/webview_flutter.dart | 3 +- .../webview_flutter/pubspec.yaml | 2 +- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index fcfaf4e5720d..df7d9cb87457 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,6 +1,8 @@ -## NEXT +## 2.0.12 * Improved the documentation on using the different Android Platform View modes. +* So that Android and iOS behave the same, `onWebResourceError` is now only called for the main + page. ## 2.0.11 diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java index 4e7056f1468c..adc84671a701 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -14,6 +14,7 @@ import android.webkit.WebResourceRequest; import android.webkit.WebView; import android.webkit.WebViewClient; +import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.webkit.WebResourceErrorCompat; import androidx.webkit.WebViewClientCompat; @@ -192,8 +193,10 @@ public void onPageFinished(WebView view, String url) { @Override public void onReceivedError( WebView view, WebResourceRequest request, WebResourceError error) { - FlutterWebViewClient.this.onWebResourceError( - error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + if (request.isForMainFrame()) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + } } @Override @@ -239,9 +242,13 @@ public void onPageFinished(WebView view, String url) { @SuppressLint("RequiresFeature") @Override public void onReceivedError( - WebView view, WebResourceRequest request, WebResourceErrorCompat error) { - FlutterWebViewClient.this.onWebResourceError( - error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + @NonNull WebView view, + @NonNull WebResourceRequest request, + @NonNull WebResourceErrorCompat error) { + if (request.isForMainFrame()) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + } } @Override diff --git a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index 876f961a353b..f3eeee156421 100644 --- a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -1139,6 +1139,7 @@ void main() { (WidgetTester tester) async { final Completer errorCompleter = Completer(); + final Completer pageFinishCompleter = Completer(); await tester.pumpWidget( Directionality( @@ -1150,13 +1151,56 @@ void main() { onWebResourceError: (WebResourceError error) { errorCompleter.complete(error); }, + onPageFinished: (_) => pageFinishCompleter.complete(), ), ), ); expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; }); + testWidgets( + 'onWebResourceError only called for main frame', + (WidgetTester tester) async { + final String iframeTest = ''' + + + + WebResourceError test + + + + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframeTest)); + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,$iframeTestBase64', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }, + ); + testWidgets('can block requests', (WidgetTester tester) async { final Completer controllerCompleter = Completer(); diff --git a/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart index 74d8af8d4687..398ac876bf3e 100644 --- a/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart @@ -369,8 +369,7 @@ class WebView extends StatefulWidget { /// Invoked when a web resource has failed to load. /// - /// This can be called for any resource (iframe, image, etc.), not just for - /// the main page. + /// This callback is only called for the main page. final WebResourceErrorCallback? onWebResourceError; /// Controls whether WebView debugging is enabled. diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index 2f00071e772e..cc5d9cdc8b96 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.11 +version: 2.0.12 environment: sdk: ">=2.12.0 <3.0.0" From e3d5ef0ddb77c880bc37a7530cd5cb0ba7523a92 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Wed, 11 Aug 2021 01:02:05 +0200 Subject: [PATCH 187/364] [camera_web] Add support for a zoom level (#4224) --- .../camera_settings_test.dart | 153 +++++++ .../example/integration_test/camera_test.dart | 174 +++++++ .../integration_test/camera_web_test.dart | 425 +++++++++++++++++- .../camera/camera_web/lib/src/camera.dart | 50 +++ .../camera_web/lib/src/camera_settings.dart | 61 +++ .../camera/camera_web/lib/src/camera_web.dart | 35 +- .../lib/src/types/camera_error_code.dart | 8 + .../camera_web/lib/src/types/types.dart | 1 + .../lib/src/types/zoom_level_capability.dart | 45 ++ packages/camera/camera_web/pubspec.yaml | 1 + .../camera/camera_web/test/helpers/mocks.dart | 3 + .../test/types/camera_error_code_test.dart | 14 + .../types/zoom_level_capability_test.dart | 47 ++ 13 files changed, 993 insertions(+), 24 deletions(-) create mode 100644 packages/camera/camera_web/lib/src/types/zoom_level_capability.dart create mode 100644 packages/camera/camera_web/test/types/zoom_level_capability_test.dart diff --git a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart index bc228b2e35c6..0e1d78789f08 100644 --- a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_settings_test.dart @@ -4,8 +4,10 @@ import 'dart:html'; import 'dart:ui'; +import 'dart:js_util' as js_util; import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/services.dart'; @@ -342,6 +344,157 @@ void main() { }); }); + group('getZoomLevelCapabilityForCamera', () { + late Camera camera; + late List videoTracks; + + setUp(() { + camera = MockCamera(); + videoTracks = [MockMediaStreamTrack(), MockMediaStreamTrack()]; + + when(() => camera.textureId).thenReturn(0); + when(() => camera.stream).thenReturn(FakeMediaStream(videoTracks)); + }); + + testWidgets( + 'returns the zoom level capability ' + 'based on the first video track', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'zoom': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'zoom': js_util.jsify({ + 'min': 100, + 'max': 400, + 'step': 2, + }), + }); + + final zoomLevelCapability = + settings.getZoomLevelCapabilityForCamera(camera); + + expect(zoomLevelCapability.minimum, equals(100.0)); + expect(zoomLevelCapability.maximum, equals(400.0)); + expect(zoomLevelCapability.videoTrack, equals(videoTracks.first)); + }); + + group('throws CameraWebException', () { + testWidgets( + 'with zoomLevelNotSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + expect( + () => settings.getZoomLevelCapabilityForCamera(camera), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + camera.textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelNotSupported, + ), + ), + ); + }); + + testWidgets( + 'with zoomLevelNotSupported error ' + 'when the zoom level is not supported ' + 'in the browser', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'zoom': false, + }); + + when(videoTracks.first.getCapabilities).thenReturn({ + 'zoom': { + 'min': 100, + 'max': 400, + 'step': 2, + }, + }); + + expect( + () => settings.getZoomLevelCapabilityForCamera(camera), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + camera.textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelNotSupported, + ), + ), + ); + }); + + testWidgets( + 'with zoomLevelNotSupported error ' + 'when the zoom level is not supported ' + 'by the camera', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'zoom': true, + }); + + when(videoTracks.first.getCapabilities).thenReturn({}); + + expect( + () => settings.getZoomLevelCapabilityForCamera(camera), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + camera.textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelNotSupported, + ), + ), + ); + }); + + testWidgets( + 'with notStarted error ' + 'when the camera stream has not been initialized', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'zoom': true, + }); + + // Create a camera stream with no video tracks. + when(() => camera.stream).thenReturn(FakeMediaStream([])); + + expect( + () => settings.getZoomLevelCapabilityForCamera(camera), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + camera.textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notStarted, + ), + ), + ); + }); + }); + }); + group('getFacingModeForVideoTrack', () { testWidgets( 'throws PlatformException ' diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 740e24f87819..03ffe81cad64 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -627,6 +627,180 @@ void main() { }); }); + group('zoomLevel', () { + group('getMaxZoomLevel', () { + testWidgets( + 'returns maximum ' + 'from CameraSettings.getZoomLevelCapabilityForCamera', + (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + final maximumZoomLevel = camera.getMaxZoomLevel(); + + verify(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .called(1); + + expect( + maximumZoomLevel, + equals(zoomLevelCapability.maximum), + ); + }); + }); + + group('getMinZoomLevel', () { + testWidgets( + 'returns minimum ' + 'from CameraSettings.getZoomLevelCapabilityForCamera', + (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + final minimumZoomLevel = camera.getMinZoomLevel(); + + verify(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .called(1); + + expect( + minimumZoomLevel, + equals(zoomLevelCapability.minimum), + ); + }); + }); + + group('setZoomLevel', () { + testWidgets( + 'applies zoom on the video track ' + 'from CameraSettings.getZoomLevelCapabilityForCamera', + (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ); + + final videoTrack = MockMediaStreamTrack(); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: videoTrack, + ); + + when(() => videoTrack.applyConstraints(any())) + .thenAnswer((_) async {}); + + when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + const zoom = 75.0; + + camera.setZoomLevel(zoom); + + verify( + () => videoTrack.applyConstraints({ + "advanced": [ + { + ZoomLevelCapability.constraintName: zoom, + } + ] + }), + ).called(1); + }); + + group('throws CameraWebException', () { + testWidgets( + 'with zoomLevelInvalid error ' + 'when the provided zoom level is below minimum', (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + expect( + () => camera.setZoomLevel(45.0), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelInvalid, + ), + )); + }); + + testWidgets( + 'with zoomLevelInvalid error ' + 'when the provided zoom level is below minimum', (tester) async { + final camera = Camera( + textureId: textureId, + cameraSettings: cameraSettings, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + expect( + () => camera.setZoomLevel(105.0), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelInvalid, + ), + )); + }); + }); + }); + }); + group('getViewType', () { testWidgets('returns a correct view type', (tester) async { final camera = Camera( diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index fda35dd088c1..eb988f49ab87 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -1131,28 +1131,302 @@ void main() { ); }); - testWidgets('getMaxZoomLevel throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.getMaxZoomLevel(cameraId), - throwsUnimplementedError, - ); + group('getMaxZoomLevel', () { + testWidgets('calls getMaxZoomLevel on the camera', (tester) async { + final camera = MockCamera(); + const maximumZoomLevel = 100.0; + + when(camera.getMaxZoomLevel).thenReturn(maximumZoomLevel); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + await CameraPlatform.instance.getMaxZoomLevel( + cameraId, + ), + equals(maximumZoomLevel), + ); + + verify(camera.getMaxZoomLevel).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () async => await CameraPlatform.instance.getMaxZoomLevel( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when getMaxZoomLevel throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(camera.getMaxZoomLevel).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.getMaxZoomLevel( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when getMaxZoomLevel throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.getMaxZoomLevel).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.getMaxZoomLevel( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('getMinZoomLevel throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.getMinZoomLevel(cameraId), - throwsUnimplementedError, - ); + group('getMinZoomLevel', () { + testWidgets('calls getMinZoomLevel on the camera', (tester) async { + final camera = MockCamera(); + const minimumZoomLevel = 100.0; + + when(camera.getMinZoomLevel).thenReturn(minimumZoomLevel); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + await CameraPlatform.instance.getMinZoomLevel( + cameraId, + ), + equals(minimumZoomLevel), + ); + + verify(camera.getMinZoomLevel).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () async => await CameraPlatform.instance.getMinZoomLevel( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when getMinZoomLevel throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(camera.getMinZoomLevel).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.getMinZoomLevel( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when getMinZoomLevel throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.getMinZoomLevel).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.getMinZoomLevel( + cameraId, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('setZoomLevel throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.setZoomLevel( - cameraId, - 1.0, - ), - throwsUnimplementedError, - ); + group('setZoomLevel', () { + testWidgets('calls setZoomLevel on the camera', (tester) async { + final camera = MockCamera(); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + const zoom = 100.0; + + await CameraPlatform.instance.setZoomLevel(cameraId, zoom); + + verify(() => camera.setZoomLevel(zoom)).called(1); + }); + + group('throws CameraException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () async => await CameraPlatform.instance.setZoomLevel( + cameraId, + 100.0, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when setZoomLevel throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(() => camera.setZoomLevel(any())).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.setZoomLevel( + cameraId, + 100.0, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when setZoomLevel throws PlatformException', + (tester) async { + final camera = MockCamera(); + final exception = PlatformException( + code: CameraErrorCode.notSupported.toString(), + message: 'message', + ); + + when(() => camera.setZoomLevel(any())).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.setZoomLevel( + cameraId, + 100.0, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code, + ), + ), + ); + }); + + testWidgets('when setZoomLevel throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(() => camera.setZoomLevel(any())).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.setZoomLevel( + cameraId, + 100.0, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); testWidgets( @@ -1538,6 +1812,121 @@ void main() { await streamQueue.cancel(); }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on getMaxZoomLevel error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.zoomLevelNotSupported, + 'description', + ); + + when(camera.getMaxZoomLevel).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => await CameraPlatform.instance.getMaxZoomLevel( + cameraId, + ), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on getMinZoomLevel error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.zoomLevelNotSupported, + 'description', + ); + + when(camera.getMinZoomLevel).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => await CameraPlatform.instance.getMinZoomLevel( + cameraId, + ), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on setZoomLevel error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.zoomLevelNotSupported, + 'description', + ); + + when(() => camera.setZoomLevel(any())).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => await CameraPlatform.instance.setZoomLevel( + cameraId, + 100.0, + ), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); }); testWidgets('onVideoRecordedEvent throws UnimplementedError', diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 1cd007b917bb..c77d36023058 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -28,6 +28,10 @@ String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; /// The camera can be played/stopped by calling [play]/[stop] /// or may capture a picture by calling [takePicture]. /// +/// The camera zoom may be adjusted with [setZoomLevel]. The provided +/// zoom level must be a value in the range of [getMinZoomLevel] to +/// [getMaxZoomLevel]. +/// /// The [textureId] is used to register a camera view with the id /// defined by [_getViewType]. class Camera { @@ -182,6 +186,9 @@ class Camera { } /// Sets the camera flash mode to [mode]. + /// + /// Throws a [CameraWebException] if the torch mode is not supported + /// or the camera has not been initialized or started. void setFlashMode(FlashMode mode) { final mediaDevices = window?.navigator.mediaDevices; final supportedConstraints = mediaDevices?.getSupportedConstraints(); @@ -203,6 +210,9 @@ class Camera { } /// Sets the camera torch mode constraint to [enabled]. + /// + /// Throws a [CameraWebException] if the torch mode is not supported + /// or the camera has not been initialized or started. void _setTorchMode({required bool enabled}) { final videoTracks = stream?.getVideoTracks() ?? []; @@ -236,6 +246,46 @@ class Camera { } } + /// Returns the camera maximum zoom level. + /// + /// Throws a [CameraWebException] if the zoom level is not supported + /// or the camera has not been initialized or started. + double getMaxZoomLevel() => + _cameraSettings.getZoomLevelCapabilityForCamera(this).maximum; + + /// Returns the camera minimum zoom level. + /// + /// Throws a [CameraWebException] if the zoom level is not supported + /// or the camera has not been initialized or started. + double getMinZoomLevel() => + _cameraSettings.getZoomLevelCapabilityForCamera(this).minimum; + + /// Sets the camera zoom level to [zoom]. + /// + /// Throws a [CameraWebException] if the zoom level is invalid, + /// not supported or the camera has not been initialized or started. + void setZoomLevel(double zoom) { + final zoomLevelCapability = + _cameraSettings.getZoomLevelCapabilityForCamera(this); + + if (zoom < zoomLevelCapability.minimum || + zoom > zoomLevelCapability.maximum) { + throw CameraWebException( + textureId, + CameraErrorCode.zoomLevelInvalid, + 'The provided zoom level must be in the range of ${zoomLevelCapability.minimum} to ${zoomLevelCapability.maximum}.', + ); + } + + zoomLevelCapability.videoTrack.applyConstraints({ + "advanced": [ + { + ZoomLevelCapability.constraintName: zoom, + } + ] + }); + } + /// Returns the registered view type of the camera. String getViewType() => _getViewType(textureId); diff --git a/packages/camera/camera_web/lib/src/camera_settings.dart b/packages/camera/camera_web/lib/src/camera_settings.dart index ce713bc52468..7d35fff84112 100644 --- a/packages/camera/camera_web/lib/src/camera_settings.dart +++ b/packages/camera/camera_web/lib/src/camera_settings.dart @@ -4,8 +4,10 @@ import 'dart:html' as html; import 'dart:ui'; +import 'dart:js_util' as js_util; import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -107,6 +109,65 @@ class CameraSettings { } } + /// Returns the zoom level capability for the given [camera]. + /// + /// Throws a [CameraWebException] if the zoom level is not supported + /// or the camera has not been initialized or started. + ZoomLevelCapability getZoomLevelCapabilityForCamera( + Camera camera, + ) { + final mediaDevices = window?.navigator.mediaDevices; + final supportedConstraints = mediaDevices?.getSupportedConstraints(); + final zoomLevelSupported = + supportedConstraints?[ZoomLevelCapability.constraintName] ?? false; + + if (!zoomLevelSupported) { + throw CameraWebException( + camera.textureId, + CameraErrorCode.zoomLevelNotSupported, + 'The zoom level is not supported in the current browser.', + ); + } + + final videoTracks = camera.stream?.getVideoTracks() ?? []; + + if (videoTracks.isNotEmpty) { + final defaultVideoTrack = videoTracks.first; + + /// The zoom level capability is represented by MediaSettingsRange. + /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaSettingsRange + final zoomLevelCapability = defaultVideoTrack + .getCapabilities()[ZoomLevelCapability.constraintName] ?? + {}; + + // The zoom level capability is a nested JS object, therefore + // we need to access its properties with the js_util library. + // See: https://api.dart.dev/stable/2.13.4/dart-js_util/getProperty.html + final minimumZoomLevel = js_util.getProperty(zoomLevelCapability, 'min'); + final maximumZoomLevel = js_util.getProperty(zoomLevelCapability, 'max'); + + if (minimumZoomLevel != null && maximumZoomLevel != null) { + return ZoomLevelCapability( + minimum: minimumZoomLevel.toDouble(), + maximum: maximumZoomLevel.toDouble(), + videoTrack: defaultVideoTrack, + ); + } else { + throw CameraWebException( + camera.textureId, + CameraErrorCode.zoomLevelNotSupported, + 'The zoom level is not supported by the current camera.', + ); + } + } else { + throw CameraWebException( + camera.textureId, + CameraErrorCode.notStarted, + 'The camera has not been initialized or started.', + ); + } + } + /// Returns a facing mode of the [videoTrack] /// (null if the facing mode is not available). String? getFacingModeForVideoTrack(html.MediaStreamTrack videoTrack) { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 0ae0d9e75c24..ecea3a76e74a 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -481,18 +481,41 @@ class CameraPlugin extends CameraPlatform { } @override - Future getMaxZoomLevel(int cameraId) { - throw UnimplementedError('getMaxZoomLevel() is not implemented.'); + Future getMaxZoomLevel(int cameraId) async { + try { + return getCamera(cameraId).getMaxZoomLevel(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override - Future getMinZoomLevel(int cameraId) { - throw UnimplementedError('getMinZoomLevel() is not implemented.'); + Future getMinZoomLevel(int cameraId) async { + try { + return getCamera(cameraId).getMinZoomLevel(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override - Future setZoomLevel(int cameraId, double zoom) { - throw UnimplementedError('setZoomLevel() is not implemented.'); + Future setZoomLevel(int cameraId, double zoom) async { + try { + getCamera(cameraId).setZoomLevel(zoom); + } on html.DomException catch (e) { + throw CameraException(e.name, e.message); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw CameraException(e.code.toString(), e.description); + } } @override diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart index 904920db6ac6..210fa2baa9d2 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_code.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -56,6 +56,14 @@ class CameraErrorCode { static const CameraErrorCode torchModeNotSupported = CameraErrorCode._('torchModeNotSupported'); + /// The camera zoom level is not supported. + static const CameraErrorCode zoomLevelNotSupported = + CameraErrorCode._('zoomLevelNotSupported'); + + /// The camera zoom level is invalid. + static const CameraErrorCode zoomLevelInvalid = + CameraErrorCode._('zoomLevelInvalid'); + /// The camera has not been initialized or started. static const CameraErrorCode notStarted = CameraErrorCode._('cameraNotStarted'); diff --git a/packages/camera/camera_web/lib/src/types/types.dart b/packages/camera/camera_web/lib/src/types/types.dart index 4e3902fcb3ee..72d7fb85af14 100644 --- a/packages/camera/camera_web/lib/src/types/types.dart +++ b/packages/camera/camera_web/lib/src/types/types.dart @@ -7,3 +7,4 @@ export 'camera_options.dart'; export 'camera_web_exception.dart'; export 'media_device_kind.dart'; export 'orientation_type.dart'; +export 'zoom_level_capability.dart'; diff --git a/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart b/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart new file mode 100644 index 000000000000..ace57140d956 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/zoom_level_capability.dart @@ -0,0 +1,45 @@ +// Copyright 2013 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:html' as html; +import 'dart:ui' show hashValues; + +/// The possible range of values for the zoom level configurable +/// on the camera video track. +class ZoomLevelCapability { + /// Creates a new instance of [ZoomLevelCapability] with the given + /// zoom level range of [minimum] to [maximum] configurable + /// on the [videoTrack]. + ZoomLevelCapability({ + required this.minimum, + required this.maximum, + required this.videoTrack, + }); + + /// The zoom level constraint name. + /// See: https://w3c.github.io/mediacapture-image/#dom-mediatracksupportedconstraints-zoom + static const constraintName = "zoom"; + + /// The minimum zoom level. + final double minimum; + + /// The maximum zoom level. + final double maximum; + + /// The video track capable of configuring the zoom level. + final html.MediaStreamTrack videoTrack; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ZoomLevelCapability && + other.minimum == minimum && + other.maximum == maximum && + other.videoTrack == videoTrack; + } + + @override + int get hashCode => hashValues(minimum, maximum, videoTrack); +} diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index a2aa43c22d65..ec674f375164 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -30,4 +30,5 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + mocktail: ^0.1.4 pedantic: ^1.11.1 \ No newline at end of file diff --git a/packages/camera/camera_web/test/helpers/mocks.dart b/packages/camera/camera_web/test/helpers/mocks.dart index 0398ad33f126..34c56632b60f 100644 --- a/packages/camera/camera_web/test/helpers/mocks.dart +++ b/packages/camera/camera_web/test/helpers/mocks.dart @@ -5,6 +5,9 @@ import 'dart:html'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} /// A fake [MediaError] that returns the provided error [_code]. class FakeMediaError extends Fake implements MediaError { diff --git a/packages/camera/camera_web/test/types/camera_error_code_test.dart b/packages/camera/camera_web/test/types/camera_error_code_test.dart index 1fec82b16f8d..c31dc6a9ffb0 100644 --- a/packages/camera/camera_web/test/types/camera_error_code_test.dart +++ b/packages/camera/camera_web/test/types/camera_error_code_test.dart @@ -89,6 +89,20 @@ void main() { ); }); + test('zoomLevelNotSupported', () { + expect( + CameraErrorCode.zoomLevelNotSupported.toString(), + equals('zoomLevelNotSupported'), + ); + }); + + test('zoomLevelInvalid', () { + expect( + CameraErrorCode.zoomLevelInvalid.toString(), + equals('zoomLevelInvalid'), + ); + }); + test('notStarted', () { expect( CameraErrorCode.notStarted.toString(), diff --git a/packages/camera/camera_web/test/types/zoom_level_capability_test.dart b/packages/camera/camera_web/test/types/zoom_level_capability_test.dart new file mode 100644 index 000000000000..c382b4b76cc4 --- /dev/null +++ b/packages/camera/camera_web/test/types/zoom_level_capability_test.dart @@ -0,0 +1,47 @@ +// Copyright 2013 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 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/helpers.dart'; + +void main() { + group('ZoomLevelCapability', () { + test('sets all properties', () { + const minimum = 100.0; + const maximum = 400.0; + final videoTrack = MockMediaStreamTrack(); + + final capability = ZoomLevelCapability( + minimum: minimum, + maximum: maximum, + videoTrack: videoTrack, + ); + + expect(capability.minimum, equals(minimum)); + expect(capability.maximum, equals(maximum)); + expect(capability.videoTrack, equals(videoTrack)); + }); + + test('supports value equality', () { + final videoTrack = MockMediaStreamTrack(); + + expect( + ZoomLevelCapability( + minimum: 0.0, + maximum: 100.0, + videoTrack: videoTrack, + ), + equals( + ZoomLevelCapability( + minimum: 0.0, + maximum: 100.0, + videoTrack: videoTrack, + ), + ), + ); + }); + }); +} From b9ac641ee37c5aa0c0d03dab88e3c302af4d1350 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Wed, 11 Aug 2021 10:57:06 -0700 Subject: [PATCH 188/364] [ci.yaml] Auto-generate LUCI configs (#4223) --- .ci.yaml | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.ci.yaml b/.ci.yaml index 92bfc040eecb..c2b7deebab14 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -8,9 +8,39 @@ enabled_branches: - master +platform_properties: + windows: + properties: + caches: >- + [ + {"name": "vsbuild", "path": "vsbuild"}, + {"name": "pub_cache", "path": ".pub-cache"} + ] + dependencies: > + [ + {"dependency": "certs"} + ] + device_type: none + os: Windows + targets: - - name: Windows Plugins - builder: Windows Plugins - postsubmit: false + - name: Windows Plugins master channel + recipe: plugins/plugins + timeout: 30 + properties: + dependencies: > + [ + {"dependency": "vs_build"} + ] scheduler: luci + - name: Windows Plugins stable channel + recipe: plugins/plugins + timeout: 30 + properties: + channel: stable + dependencies: > + [ + {"dependency": "vs_build"} + ] + scheduler: luci From 77029678d2f2ae462f04de0ad96d361e516fd453 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 12 Aug 2021 00:32:06 +0200 Subject: [PATCH 189/364] [camera_web] Rename `CameraSettings` to `CameraService` (#4225) --- .../camera_error_code_test.dart | 45 +++---- .../camera_metadata_test.dart | 5 +- .../camera_options_test.dart | 39 +++--- ...ngs_test.dart => camera_service_test.dart} | 115 +++++++++--------- .../example/integration_test/camera_test.dart | 95 +++++++-------- .../camera_web_exception_test.dart | 7 +- .../integration_test/camera_web_test.dart | 85 +++++++------ .../integration_test/helpers/mocks.dart | 4 +- .../zoom_level_capability_test.dart | 9 +- .../camera/camera_web/lib/src/camera.dart | 22 ++-- ...mera_settings.dart => camera_service.dart} | 4 +- .../camera/camera_web/lib/src/camera_web.dart | 31 +++-- packages/camera/camera_web/pubspec.yaml | 1 - .../camera_web/test/helpers/helpers.dart | 5 - .../camera/camera_web/test/helpers/mocks.dart | 20 --- 15 files changed, 239 insertions(+), 248 deletions(-) rename packages/camera/camera_web/{test/types => example/integration_test}/camera_error_code_test.dart (73%) rename packages/camera/camera_web/{test/types => example/integration_test}/camera_metadata_test.dart (76%) rename packages/camera/camera_web/{test/types => example/integration_test}/camera_options_test.dart (82%) rename packages/camera/camera_web/example/integration_test/{camera_settings_test.dart => camera_service_test.dart} (86%) rename packages/camera/camera_web/{test/types => example/integration_test}/camera_web_exception_test.dart (80%) rename packages/camera/camera_web/{test/types => example/integration_test}/zoom_level_capability_test.dart (80%) rename packages/camera/camera_web/lib/src/{camera_settings.dart => camera_service.dart} (99%) delete mode 100644 packages/camera/camera_web/test/helpers/helpers.dart delete mode 100644 packages/camera/camera_web/test/helpers/mocks.dart diff --git a/packages/camera/camera_web/test/types/camera_error_code_test.dart b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart similarity index 73% rename from packages/camera/camera_web/test/types/camera_error_code_test.dart rename to packages/camera/camera_web/example/integration_test/camera_error_code_test.dart index c31dc6a9ffb0..d0250c6e4e26 100644 --- a/packages/camera/camera_web/test/types/camera_error_code_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart @@ -6,111 +6,114 @@ import 'dart:html'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; -import '../helpers/helpers.dart'; +import 'helpers/helpers.dart'; void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('CameraErrorCode', () { group('toString returns a correct type for', () { - test('notSupported', () { + testWidgets('notSupported', (tester) async { expect( CameraErrorCode.notSupported.toString(), equals('cameraNotSupported'), ); }); - test('notFound', () { + testWidgets('notFound', (tester) async { expect( CameraErrorCode.notFound.toString(), equals('cameraNotFound'), ); }); - test('notReadable', () { + testWidgets('notReadable', (tester) async { expect( CameraErrorCode.notReadable.toString(), equals('cameraNotReadable'), ); }); - test('overconstrained', () { + testWidgets('overconstrained', (tester) async { expect( CameraErrorCode.overconstrained.toString(), equals('cameraOverconstrained'), ); }); - test('permissionDenied', () { + testWidgets('permissionDenied', (tester) async { expect( CameraErrorCode.permissionDenied.toString(), equals('cameraPermission'), ); }); - test('type', () { + testWidgets('type', (tester) async { expect( CameraErrorCode.type.toString(), equals('cameraType'), ); }); - test('abort', () { + testWidgets('abort', (tester) async { expect( CameraErrorCode.abort.toString(), equals('cameraAbort'), ); }); - test('security', () { + testWidgets('security', (tester) async { expect( CameraErrorCode.security.toString(), equals('cameraSecurity'), ); }); - test('missingMetadata', () { + testWidgets('missingMetadata', (tester) async { expect( CameraErrorCode.missingMetadata.toString(), equals('cameraMissingMetadata'), ); }); - test('orientationNotSupported', () { + testWidgets('orientationNotSupported', (tester) async { expect( CameraErrorCode.orientationNotSupported.toString(), equals('orientationNotSupported'), ); }); - test('torchModeNotSupported', () { + testWidgets('torchModeNotSupported', (tester) async { expect( CameraErrorCode.torchModeNotSupported.toString(), equals('torchModeNotSupported'), ); }); - test('zoomLevelNotSupported', () { + testWidgets('zoomLevelNotSupported', (tester) async { expect( CameraErrorCode.zoomLevelNotSupported.toString(), equals('zoomLevelNotSupported'), ); }); - test('zoomLevelInvalid', () { + testWidgets('zoomLevelInvalid', (tester) async { expect( CameraErrorCode.zoomLevelInvalid.toString(), equals('zoomLevelInvalid'), ); }); - test('notStarted', () { + testWidgets('notStarted', (tester) async { expect( CameraErrorCode.notStarted.toString(), equals('cameraNotStarted'), ); }); - test('unknown', () { + testWidgets('unknown', (tester) async { expect( CameraErrorCode.unknown.toString(), equals('cameraUnknown'), @@ -118,7 +121,7 @@ void main() { }); group('fromMediaError', () { - test('with aborted error code', () { + testWidgets('with aborted error code', (tester) async { expect( CameraErrorCode.fromMediaError( FakeMediaError(MediaError.MEDIA_ERR_ABORTED), @@ -127,7 +130,7 @@ void main() { ); }); - test('with network error code', () { + testWidgets('with network error code', (tester) async { expect( CameraErrorCode.fromMediaError( FakeMediaError(MediaError.MEDIA_ERR_NETWORK), @@ -136,7 +139,7 @@ void main() { ); }); - test('with decode error code', () { + testWidgets('with decode error code', (tester) async { expect( CameraErrorCode.fromMediaError( FakeMediaError(MediaError.MEDIA_ERR_DECODE), @@ -145,7 +148,7 @@ void main() { ); }); - test('with source not supported error code', () { + testWidgets('with source not supported error code', (tester) async { expect( CameraErrorCode.fromMediaError( FakeMediaError(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED), @@ -154,7 +157,7 @@ void main() { ); }); - test('with unknown error code', () { + testWidgets('with unknown error code', (tester) async { expect( CameraErrorCode.fromMediaError( FakeMediaError(5), diff --git a/packages/camera/camera_web/test/types/camera_metadata_test.dart b/packages/camera/camera_web/example/integration_test/camera_metadata_test.dart similarity index 76% rename from packages/camera/camera_web/test/types/camera_metadata_test.dart rename to packages/camera/camera_web/example/integration_test/camera_metadata_test.dart index c76688f768d7..36ecb3e47f31 100644 --- a/packages/camera/camera_web/test/types/camera_metadata_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_metadata_test.dart @@ -4,10 +4,13 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('CameraMetadata', () { - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( CameraMetadata( deviceId: 'deviceId', diff --git a/packages/camera/camera_web/test/types/camera_options_test.dart b/packages/camera/camera_web/example/integration_test/camera_options_test.dart similarity index 82% rename from packages/camera/camera_web/test/types/camera_options_test.dart rename to packages/camera/camera_web/example/integration_test/camera_options_test.dart index 6f60bfd5aeda..a74ba3088394 100644 --- a/packages/camera/camera_web/test/types/camera_options_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_options_test.dart @@ -4,10 +4,13 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('CameraOptions', () { - test('serializes correctly', () { + testWidgets('serializes correctly', (tester) async { final cameraOptions = CameraOptions( audio: AudioConstraints(enabled: true), video: VideoConstraints( @@ -24,7 +27,7 @@ void main() { ); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( CameraOptions( audio: AudioConstraints(enabled: false), @@ -51,14 +54,14 @@ void main() { }); group('AudioConstraints', () { - test('serializes correctly', () { + testWidgets('serializes correctly', (tester) async { expect( AudioConstraints(enabled: true).toJson(), equals(true), ); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( AudioConstraints(enabled: true), equals(AudioConstraints(enabled: true)), @@ -67,7 +70,7 @@ void main() { }); group('VideoConstraints', () { - test('serializes correctly', () { + testWidgets('serializes correctly', (tester) async { final videoConstraints = VideoConstraints( facingMode: FacingModeConstraint.exact(CameraType.user), width: VideoSizeConstraint(ideal: 100, maximum: 100), @@ -88,7 +91,7 @@ void main() { ); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( VideoConstraints( facingMode: FacingModeConstraint.exact(CameraType.environment), @@ -110,25 +113,25 @@ void main() { group('FacingModeConstraint', () { group('ideal', () { - test( + testWidgets( 'serializes correctly ' - 'for environment camera type', () { + 'for environment camera type', (tester) async { expect( FacingModeConstraint(CameraType.environment).toJson(), equals({'ideal': 'environment'}), ); }); - test( + testWidgets( 'serializes correctly ' - 'for user camera type', () { + 'for user camera type', (tester) async { expect( FacingModeConstraint(CameraType.user).toJson(), equals({'ideal': 'user'}), ); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( FacingModeConstraint(CameraType.user), equals(FacingModeConstraint(CameraType.user)), @@ -137,25 +140,25 @@ void main() { }); group('exact', () { - test( + testWidgets( 'serializes correctly ' - 'for environment camera type', () { + 'for environment camera type', (tester) async { expect( FacingModeConstraint.exact(CameraType.environment).toJson(), equals({'exact': 'environment'}), ); }); - test( + testWidgets( 'serializes correctly ' - 'for user camera type', () { + 'for user camera type', (tester) async { expect( FacingModeConstraint.exact(CameraType.user).toJson(), equals({'exact': 'user'}), ); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( FacingModeConstraint.exact(CameraType.environment), equals(FacingModeConstraint.exact(CameraType.environment)), @@ -165,7 +168,7 @@ void main() { }); group('VideoSizeConstraint ', () { - test('serializes correctly', () { + testWidgets('serializes correctly', (tester) async { expect( VideoSizeConstraint( minimum: 200, @@ -180,7 +183,7 @@ void main() { ); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { expect( VideoSizeConstraint( minimum: 100, diff --git a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart b/packages/camera/camera_web/example/integration_test/camera_service_test.dart similarity index 86% rename from packages/camera/camera_web/example/integration_test/camera_settings_test.dart rename to packages/camera/camera_web/example/integration_test/camera_service_test.dart index 0e1d78789f08..161aeb4a595e 100644 --- a/packages/camera/camera_web/example/integration_test/camera_settings_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_service_test.dart @@ -8,7 +8,7 @@ import 'dart:js_util' as js_util; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; -import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -20,13 +20,13 @@ import 'helpers/helpers.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('CameraSettings', () { + group('CameraService', () { const cameraId = 0; late Window window; late Navigator navigator; late MediaDevices mediaDevices; - late CameraSettings settings; + late CameraService cameraService; setUp(() async { window = MockWindow(); @@ -36,7 +36,7 @@ void main() { when(() => window.navigator).thenReturn(navigator); when(() => navigator.mediaDevices).thenReturn(mediaDevices); - settings = CameraSettings()..window = window; + cameraService = CameraService()..window = window; }); group('getMediaStreamForOptions', () { @@ -53,7 +53,7 @@ void main() { ), ); - await settings.getMediaStreamForOptions(options); + await cameraService.getMediaStreamForOptions(options); verify( () => mediaDevices.getUserMedia(options.toJson()), @@ -67,7 +67,7 @@ void main() { when(() => navigator.mediaDevices).thenReturn(null); expect( - () => settings.getMediaStreamForOptions(CameraOptions()), + () => cameraService.getMediaStreamForOptions(CameraOptions()), throwsA( isA().having( (e) => e.code, @@ -87,7 +87,7 @@ void main() { .thenThrow(FakeDomException('NotFoundError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -107,7 +107,7 @@ void main() { .thenThrow(FakeDomException('DevicesNotFoundError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -127,7 +127,7 @@ void main() { .thenThrow(FakeDomException('NotReadableError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -147,7 +147,7 @@ void main() { .thenThrow(FakeDomException('TrackStartError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -167,7 +167,7 @@ void main() { .thenThrow(FakeDomException('OverconstrainedError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -188,7 +188,7 @@ void main() { .thenThrow(FakeDomException('ConstraintNotSatisfiedError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -209,7 +209,7 @@ void main() { .thenThrow(FakeDomException('NotAllowedError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -230,7 +230,7 @@ void main() { .thenThrow(FakeDomException('PermissionDeniedError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -251,7 +251,7 @@ void main() { .thenThrow(FakeDomException('TypeError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -271,7 +271,7 @@ void main() { .thenThrow(FakeDomException('AbortError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -291,7 +291,7 @@ void main() { .thenThrow(FakeDomException('SecurityError')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -311,7 +311,7 @@ void main() { .thenThrow(FakeDomException('Unknown')); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -330,7 +330,7 @@ void main() { when(() => mediaDevices.getUserMedia(any())).thenThrow(Exception()); expect( - () => settings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions(), cameraId: cameraId, ), @@ -372,7 +372,7 @@ void main() { }); final zoomLevelCapability = - settings.getZoomLevelCapabilityForCamera(camera); + cameraService.getZoomLevelCapabilityForCamera(camera); expect(zoomLevelCapability.minimum, equals(100.0)); expect(zoomLevelCapability.maximum, equals(400.0)); @@ -386,7 +386,7 @@ void main() { when(() => navigator.mediaDevices).thenReturn(null); expect( - () => settings.getZoomLevelCapabilityForCamera(camera), + () => cameraService.getZoomLevelCapabilityForCamera(camera), throwsA( isA() .having( @@ -420,7 +420,7 @@ void main() { }); expect( - () => settings.getZoomLevelCapabilityForCamera(camera), + () => cameraService.getZoomLevelCapabilityForCamera(camera), throwsA( isA() .having( @@ -448,7 +448,7 @@ void main() { when(videoTracks.first.getCapabilities).thenReturn({}); expect( - () => settings.getZoomLevelCapabilityForCamera(camera), + () => cameraService.getZoomLevelCapabilityForCamera(camera), throwsA( isA() .having( @@ -476,7 +476,7 @@ void main() { when(() => camera.stream).thenReturn(FakeMediaStream([])); expect( - () => settings.getZoomLevelCapabilityForCamera(camera), + () => cameraService.getZoomLevelCapabilityForCamera(camera), throwsA( isA() .having( @@ -503,7 +503,8 @@ void main() { when(() => navigator.mediaDevices).thenReturn(null); expect( - () => settings.getFacingModeForVideoTrack(MockMediaStreamTrack()), + () => + cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()), throwsA( isA().having( (e) => e.code, @@ -522,7 +523,7 @@ void main() { }); final facingMode = - settings.getFacingModeForVideoTrack(MockMediaStreamTrack()); + cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()); expect( facingMode, @@ -544,7 +545,8 @@ void main() { when(videoTrack.getSettings).thenReturn({'facingMode': 'user'}); - final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + final facingMode = + cameraService.getFacingModeForVideoTrack(videoTrack); expect( facingMode, @@ -563,7 +565,8 @@ void main() { 'facingMode': ['environment', 'left'] }); - final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + final facingMode = + cameraService.getFacingModeForVideoTrack(videoTrack); expect( facingMode, @@ -580,7 +583,8 @@ void main() { when(videoTrack.getSettings).thenReturn({}); when(videoTrack.getCapabilities).thenReturn({'facingMode': []}); - final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + final facingMode = + cameraService.getFacingModeForVideoTrack(videoTrack); expect( facingMode, @@ -597,7 +601,8 @@ void main() { when(videoTrack.getSettings).thenReturn({}); when(videoTrack.getCapabilities).thenThrow(JSNoSuchMethodError()); - final facingMode = settings.getFacingModeForVideoTrack(videoTrack); + final facingMode = + cameraService.getFacingModeForVideoTrack(videoTrack); expect( facingMode, @@ -616,7 +621,7 @@ void main() { when(videoTrack.getCapabilities).thenThrow(Exception('Unknown')); expect( - () => settings.getFacingModeForVideoTrack(videoTrack), + () => cameraService.getFacingModeForVideoTrack(videoTrack), throwsA( isA().having( (e) => e.code, @@ -634,7 +639,7 @@ void main() { 'returns front ' 'when the facing mode is user', (tester) async { expect( - settings.mapFacingModeToLensDirection('user'), + cameraService.mapFacingModeToLensDirection('user'), equals(CameraLensDirection.front), ); }); @@ -643,7 +648,7 @@ void main() { 'returns back ' 'when the facing mode is environment', (tester) async { expect( - settings.mapFacingModeToLensDirection('environment'), + cameraService.mapFacingModeToLensDirection('environment'), equals(CameraLensDirection.back), ); }); @@ -652,7 +657,7 @@ void main() { 'returns external ' 'when the facing mode is left', (tester) async { expect( - settings.mapFacingModeToLensDirection('left'), + cameraService.mapFacingModeToLensDirection('left'), equals(CameraLensDirection.external), ); }); @@ -661,7 +666,7 @@ void main() { 'returns external ' 'when the facing mode is right', (tester) async { expect( - settings.mapFacingModeToLensDirection('right'), + cameraService.mapFacingModeToLensDirection('right'), equals(CameraLensDirection.external), ); }); @@ -672,7 +677,7 @@ void main() { 'returns user ' 'when the facing mode is user', (tester) async { expect( - settings.mapFacingModeToCameraType('user'), + cameraService.mapFacingModeToCameraType('user'), equals(CameraType.user), ); }); @@ -681,7 +686,7 @@ void main() { 'returns environment ' 'when the facing mode is environment', (tester) async { expect( - settings.mapFacingModeToCameraType('environment'), + cameraService.mapFacingModeToCameraType('environment'), equals(CameraType.environment), ); }); @@ -690,7 +695,7 @@ void main() { 'returns user ' 'when the facing mode is left', (tester) async { expect( - settings.mapFacingModeToCameraType('left'), + cameraService.mapFacingModeToCameraType('left'), equals(CameraType.user), ); }); @@ -699,7 +704,7 @@ void main() { 'returns user ' 'when the facing mode is right', (tester) async { expect( - settings.mapFacingModeToCameraType('right'), + cameraService.mapFacingModeToCameraType('right'), equals(CameraType.user), ); }); @@ -710,7 +715,7 @@ void main() { 'returns 3840x2160 ' 'when the resolution preset is max', (tester) async { expect( - settings.mapResolutionPresetToSize(ResolutionPreset.max), + cameraService.mapResolutionPresetToSize(ResolutionPreset.max), equals(Size(3840, 2160)), ); }); @@ -719,7 +724,7 @@ void main() { 'returns 3840x2160 ' 'when the resolution preset is ultraHigh', (tester) async { expect( - settings.mapResolutionPresetToSize(ResolutionPreset.ultraHigh), + cameraService.mapResolutionPresetToSize(ResolutionPreset.ultraHigh), equals(Size(3840, 2160)), ); }); @@ -728,7 +733,7 @@ void main() { 'returns 1920x1080 ' 'when the resolution preset is veryHigh', (tester) async { expect( - settings.mapResolutionPresetToSize(ResolutionPreset.veryHigh), + cameraService.mapResolutionPresetToSize(ResolutionPreset.veryHigh), equals(Size(1920, 1080)), ); }); @@ -737,7 +742,7 @@ void main() { 'returns 1280x720 ' 'when the resolution preset is high', (tester) async { expect( - settings.mapResolutionPresetToSize(ResolutionPreset.high), + cameraService.mapResolutionPresetToSize(ResolutionPreset.high), equals(Size(1280, 720)), ); }); @@ -746,7 +751,7 @@ void main() { 'returns 720x480 ' 'when the resolution preset is medium', (tester) async { expect( - settings.mapResolutionPresetToSize(ResolutionPreset.medium), + cameraService.mapResolutionPresetToSize(ResolutionPreset.medium), equals(Size(720, 480)), ); }); @@ -755,7 +760,7 @@ void main() { 'returns 320x240 ' 'when the resolution preset is low', (tester) async { expect( - settings.mapResolutionPresetToSize(ResolutionPreset.low), + cameraService.mapResolutionPresetToSize(ResolutionPreset.low), equals(Size(320, 240)), ); }); @@ -766,7 +771,7 @@ void main() { 'returns portraitPrimary ' 'when the device orientation is portraitUp', (tester) async { expect( - settings.mapDeviceOrientationToOrientationType( + cameraService.mapDeviceOrientationToOrientationType( DeviceOrientation.portraitUp, ), equals(OrientationType.portraitPrimary), @@ -777,7 +782,7 @@ void main() { 'returns landscapePrimary ' 'when the device orientation is landscapeLeft', (tester) async { expect( - settings.mapDeviceOrientationToOrientationType( + cameraService.mapDeviceOrientationToOrientationType( DeviceOrientation.landscapeLeft, ), equals(OrientationType.landscapePrimary), @@ -788,7 +793,7 @@ void main() { 'returns portraitSecondary ' 'when the device orientation is portraitDown', (tester) async { expect( - settings.mapDeviceOrientationToOrientationType( + cameraService.mapDeviceOrientationToOrientationType( DeviceOrientation.portraitDown, ), equals(OrientationType.portraitSecondary), @@ -799,7 +804,7 @@ void main() { 'returns landscapeSecondary ' 'when the device orientation is landscapeRight', (tester) async { expect( - settings.mapDeviceOrientationToOrientationType( + cameraService.mapDeviceOrientationToOrientationType( DeviceOrientation.landscapeRight, ), equals(OrientationType.landscapeSecondary), @@ -812,7 +817,7 @@ void main() { 'returns portraitUp ' 'when the orientation type is portraitPrimary', (tester) async { expect( - settings.mapOrientationTypeToDeviceOrientation( + cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.portraitPrimary, ), equals(DeviceOrientation.portraitUp), @@ -823,7 +828,7 @@ void main() { 'returns landscapeLeft ' 'when the orientation type is landscapePrimary', (tester) async { expect( - settings.mapOrientationTypeToDeviceOrientation( + cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.landscapePrimary, ), equals(DeviceOrientation.landscapeLeft), @@ -834,7 +839,7 @@ void main() { 'returns portraitDown ' 'when the orientation type is portraitSecondary', (tester) async { expect( - settings.mapOrientationTypeToDeviceOrientation( + cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.portraitSecondary, ), equals(DeviceOrientation.portraitDown), @@ -845,7 +850,7 @@ void main() { 'returns portraitDown ' 'when the orientation type is portraitSecondary', (tester) async { expect( - settings.mapOrientationTypeToDeviceOrientation( + cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.portraitSecondary, ), equals(DeviceOrientation.portraitDown), @@ -856,7 +861,7 @@ void main() { 'returns landscapeRight ' 'when the orientation type is landscapeSecondary', (tester) async { expect( - settings.mapOrientationTypeToDeviceOrientation( + cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.landscapeSecondary, ), equals(DeviceOrientation.landscapeRight), @@ -867,7 +872,7 @@ void main() { 'returns portraitUp ' 'for an unknown orientation type', (tester) async { expect( - settings.mapOrientationTypeToDeviceOrientation( + cameraService.mapOrientationTypeToDeviceOrientation( 'unknown', ), equals(DeviceOrientation.portraitUp), diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 03ffe81cad64..5c3d842502ba 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -7,7 +7,7 @@ import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; -import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -26,7 +26,7 @@ void main() { late MediaDevices mediaDevices; late MediaStream mediaStream; - late CameraSettings cameraSettings; + late CameraService cameraService; setUp(() { window = MockWindow(); @@ -36,13 +36,13 @@ void main() { when(() => window.navigator).thenReturn(navigator); when(() => navigator.mediaDevices).thenReturn(mediaDevices); - cameraSettings = MockCameraSettings(); + cameraService = MockCameraService(); final videoElement = getVideoElementWithBlankStream(Size(10, 10)); mediaStream = videoElement.captureStream(); when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( any(), cameraId: any(named: 'cameraId'), ), @@ -55,7 +55,7 @@ void main() { group('initialize', () { testWidgets( - 'calls CameraSettings.getMediaStreamForOptions ' + 'calls CameraService.getMediaStreamForOptions ' 'with provided options', (tester) async { final options = CameraOptions( video: VideoConstraints( @@ -67,13 +67,13 @@ void main() { final camera = Camera( textureId: textureId, options: options, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); verify( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( options, cameraId: textureId, ), @@ -90,7 +90,7 @@ void main() { options: CameraOptions( audio: audioConstraints, ), - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -115,7 +115,7 @@ void main() { 'with correct properties', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -128,7 +128,7 @@ void main() { testWidgets('initializes the camera stream', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -138,16 +138,15 @@ void main() { testWidgets( 'throws an exception ' - 'when CameraSettings.getMediaStreamForOptions throws', - (tester) async { + 'when CameraService.getMediaStreamForOptions throws', (tester) async { final exception = Exception('A media stream exception occured.'); - when(() => cameraSettings.getMediaStreamForOptions(any(), + when(() => cameraService.getMediaStreamForOptions(any(), cameraId: any(named: 'cameraId'))).thenThrow(exception); final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); expect( @@ -163,7 +162,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -180,7 +179,7 @@ void main() { testWidgets( 'initializes the camera stream ' - 'from CameraSettings.getMediaStreamForOptions ' + 'from CameraService.getMediaStreamForOptions ' 'if it does not exist', (tester) async { final options = CameraOptions( video: VideoConstraints( @@ -191,7 +190,7 @@ void main() { final camera = Camera( textureId: textureId, options: options, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -204,7 +203,7 @@ void main() { // Should be called twice: for initialize and play. verify( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( options, cameraId: textureId, ), @@ -219,7 +218,7 @@ void main() { testWidgets('resets the camera stream', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -236,7 +235,7 @@ void main() { testWidgets('returns a captured picture', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -272,7 +271,7 @@ void main() { testWidgets('if the flash mode is auto', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream @@ -307,7 +306,7 @@ void main() { testWidgets('if the flash mode is always', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream @@ -352,7 +351,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -372,7 +371,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -409,7 +408,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream; @@ -437,7 +436,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream; @@ -468,7 +467,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream; @@ -494,7 +493,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream; @@ -531,7 +530,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream; @@ -568,7 +567,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ) ..window = window ..stream = videoStream; @@ -604,7 +603,7 @@ void main() { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, )..window = window; expect( @@ -631,11 +630,11 @@ void main() { group('getMaxZoomLevel', () { testWidgets( 'returns maximum ' - 'from CameraSettings.getZoomLevelCapabilityForCamera', + 'from CameraService.getZoomLevelCapabilityForCamera', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); final zoomLevelCapability = ZoomLevelCapability( @@ -644,12 +643,12 @@ void main() { videoTrack: MockMediaStreamTrack(), ); - when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .thenReturn(zoomLevelCapability); final maximumZoomLevel = camera.getMaxZoomLevel(); - verify(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + verify(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .called(1); expect( @@ -662,11 +661,11 @@ void main() { group('getMinZoomLevel', () { testWidgets( 'returns minimum ' - 'from CameraSettings.getZoomLevelCapabilityForCamera', + 'from CameraService.getZoomLevelCapabilityForCamera', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); final zoomLevelCapability = ZoomLevelCapability( @@ -675,12 +674,12 @@ void main() { videoTrack: MockMediaStreamTrack(), ); - when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .thenReturn(zoomLevelCapability); final minimumZoomLevel = camera.getMinZoomLevel(); - verify(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + verify(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .called(1); expect( @@ -693,11 +692,11 @@ void main() { group('setZoomLevel', () { testWidgets( 'applies zoom on the video track ' - 'from CameraSettings.getZoomLevelCapabilityForCamera', + 'from CameraService.getZoomLevelCapabilityForCamera', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); final videoTrack = MockMediaStreamTrack(); @@ -711,7 +710,7 @@ void main() { when(() => videoTrack.applyConstraints(any())) .thenAnswer((_) async {}); - when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .thenReturn(zoomLevelCapability); const zoom = 75.0; @@ -735,7 +734,7 @@ void main() { 'when the provided zoom level is below minimum', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); final zoomLevelCapability = ZoomLevelCapability( @@ -744,7 +743,7 @@ void main() { videoTrack: MockMediaStreamTrack(), ); - when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .thenReturn(zoomLevelCapability); expect( @@ -769,7 +768,7 @@ void main() { 'when the provided zoom level is below minimum', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); final zoomLevelCapability = ZoomLevelCapability( @@ -778,7 +777,7 @@ void main() { videoTrack: MockMediaStreamTrack(), ); - when(() => cameraSettings.getZoomLevelCapabilityForCamera(camera)) + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) .thenReturn(zoomLevelCapability); expect( @@ -805,7 +804,7 @@ void main() { testWidgets('returns a correct view type', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); @@ -821,7 +820,7 @@ void main() { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( textureId: textureId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); await camera.initialize(); diff --git a/packages/camera/camera_web/test/types/camera_web_exception_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_exception_test.dart similarity index 80% rename from packages/camera/camera_web/test/types/camera_web_exception_test.dart rename to packages/camera/camera_web/example/integration_test/camera_web_exception_test.dart index d58512b460e2..6f8531b6f4af 100644 --- a/packages/camera/camera_web/test/types/camera_web_exception_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_exception_test.dart @@ -4,10 +4,13 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('CameraWebException', () { - test('sets all properties', () { + testWidgets('sets all properties', (tester) async { final cameraId = 1; final code = CameraErrorCode.notFound; final description = 'The camera is not found.'; @@ -19,7 +22,7 @@ void main() { expect(exception.description, equals(description)); }); - test('toString includes all properties', () { + testWidgets('toString includes all properties', (tester) async { final cameraId = 2; final code = CameraErrorCode.notReadable; final description = 'The camera is not readable.'; diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index eb988f49ab87..ada5c2da1a29 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -10,10 +10,10 @@ import 'package:async/async.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/camera_web.dart'; import 'package:camera_web/src/camera.dart'; -import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/types/types.dart'; -import 'package:flutter/widgets.dart' as widgets; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' as widgets; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -35,7 +35,7 @@ void main() { late Document document; late Element documentElement; - late CameraSettings cameraSettings; + late CameraService cameraService; setUp(() async { window = MockWindow(); @@ -59,10 +59,10 @@ void main() { when(() => document.documentElement).thenReturn(documentElement); when(() => window.document).thenReturn(document); - cameraSettings = MockCameraSettings(); + cameraService = MockCameraService(); when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( any(), cameraId: any(named: 'cameraId'), ), @@ -71,7 +71,7 @@ void main() { ); CameraPlatform.instance = CameraPlugin( - cameraSettings: cameraSettings, + cameraService: cameraService, )..window = window; }); @@ -88,7 +88,7 @@ void main() { group('availableCameras', () { setUp(() { when( - () => cameraSettings.getFacingModeForVideoTrack( + () => cameraService.getFacingModeForVideoTrack( any(), ), ).thenReturn(null); @@ -102,7 +102,7 @@ void main() { final _ = await CameraPlatform.instance.availableCameras(); verify( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( audio: AudioConstraints(enabled: true), ), @@ -126,7 +126,7 @@ void main() { final _ = await CameraPlatform.instance.availableCameras(); verify( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( video: VideoConstraints( deviceId: videoDevice.deviceId, @@ -153,7 +153,7 @@ void main() { final _ = await CameraPlatform.instance.availableCameras(); verifyNever( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( video: VideoConstraints( deviceId: videoDevice.deviceId, @@ -177,7 +177,7 @@ void main() { FakeMediaStream([MockMediaStreamTrack(), MockMediaStreamTrack()]); when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: videoDevice.deviceId), ), @@ -191,7 +191,7 @@ void main() { final _ = await CameraPlatform.instance.availableCameras(); verify( - () => cameraSettings.getFacingModeForVideoTrack( + () => cameraService.getFacingModeForVideoTrack( videoStream.getVideoTracks().first, ), ).called(1); @@ -239,46 +239,46 @@ void main() { ]), ); - // Mock camera settings to return the first video stream + // Mock camera service to return the first video stream // for the first video device. when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: firstVideoDevice.deviceId), ), ), ).thenAnswer((_) => Future.value(firstVideoStream)); - // Mock camera settings to return the second video stream + // Mock camera service to return the second video stream // for the second video device. when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: secondVideoDevice.deviceId), ), ), ).thenAnswer((_) => Future.value(secondVideoStream)); - // Mock camera settings to return a user facing mode + // Mock camera service to return a user facing mode // for the first video stream. when( - () => cameraSettings.getFacingModeForVideoTrack( + () => cameraService.getFacingModeForVideoTrack( firstVideoStream.getVideoTracks().first, ), ).thenReturn('user'); - when(() => cameraSettings.mapFacingModeToLensDirection('user')) + when(() => cameraService.mapFacingModeToLensDirection('user')) .thenReturn(CameraLensDirection.front); - // Mock camera settings to return an environment facing mode + // Mock camera service to return an environment facing mode // for the second video stream. when( - () => cameraSettings.getFacingModeForVideoTrack( + () => cameraService.getFacingModeForVideoTrack( secondVideoStream.getVideoTracks().first, ), ).thenReturn('environment'); - when(() => cameraSettings.mapFacingModeToLensDirection('environment')) + when(() => cameraService.mapFacingModeToLensDirection('environment')) .thenReturn(CameraLensDirection.back); final cameras = await CameraPlatform.instance.availableCameras(); @@ -318,7 +318,7 @@ void main() { ); when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( CameraOptions( video: VideoConstraints(deviceId: videoDevice.deviceId), ), @@ -326,12 +326,12 @@ void main() { ).thenAnswer((_) => Future.value(videoStream)); when( - () => cameraSettings.getFacingModeForVideoTrack( + () => cameraService.getFacingModeForVideoTrack( videoStream.getVideoTracks().first, ), ).thenReturn('left'); - when(() => cameraSettings.mapFacingModeToLensDirection('left')) + when(() => cameraService.mapFacingModeToLensDirection('left')) .thenReturn(CameraLensDirection.external); final camera = (await CameraPlatform.instance.availableCameras()).first; @@ -384,7 +384,7 @@ void main() { }); testWidgets( - 'when CameraSettings.getMediaStreamForOptions ' + 'when CameraService.getMediaStreamForOptions ' 'throws CameraWebException', (tester) async { final exception = CameraWebException( cameraId, @@ -392,7 +392,7 @@ void main() { 'description', ); - when(() => cameraSettings.getMediaStreamForOptions(any())) + when(() => cameraService.getMediaStreamForOptions(any())) .thenThrow(exception); expect( @@ -408,14 +408,14 @@ void main() { }); testWidgets( - 'when CameraSettings.getMediaStreamForOptions ' + 'when CameraService.getMediaStreamForOptions ' 'throws PlatformException', (tester) async { final exception = PlatformException( code: CameraErrorCode.notSupported.toString(), message: 'message', ); - when(() => cameraSettings.getMediaStreamForOptions(any())) + when(() => cameraService.getMediaStreamForOptions(any())) .thenThrow(exception); expect( @@ -454,13 +454,13 @@ void main() { .camerasMetadata[cameraDescription] = cameraMetadata; when( - () => cameraSettings.mapFacingModeToCameraType('user'), + () => cameraService.mapFacingModeToCameraType('user'), ).thenReturn(CameraType.user); }); testWidgets('with appropriate options', (tester) async { when( - () => cameraSettings + () => cameraService .mapResolutionPresetToSize(ResolutionPreset.ultraHigh), ).thenReturn(ultraHighResolutionSize); @@ -503,8 +503,7 @@ void main() { 'and enabled audio set to false ' 'when no options are specified', (tester) async { when( - () => - cameraSettings.mapResolutionPresetToSize(ResolutionPreset.max), + () => cameraService.mapResolutionPresetToSize(ResolutionPreset.max), ).thenReturn(maxResolutionSize); final cameraId = await CameraPlatform.instance.createCamera( @@ -657,7 +656,7 @@ void main() { group('lockCaptureOrientation', () { setUp(() { when( - () => cameraSettings.mapDeviceOrientationToOrientationType(any()), + () => cameraService.mapDeviceOrientationToOrientationType(any()), ).thenReturn(OrientationType.portraitPrimary); }); @@ -676,7 +675,7 @@ void main() { 'locks the capture orientation ' 'based on the given device orientation', (tester) async { when( - () => cameraSettings.mapDeviceOrientationToOrientationType( + () => cameraService.mapDeviceOrientationToOrientationType( DeviceOrientation.landscapeRight, ), ).thenReturn(OrientationType.landscapeSecondary); @@ -687,7 +686,7 @@ void main() { ); verify( - () => cameraSettings.mapDeviceOrientationToOrientationType( + () => cameraService.mapDeviceOrientationToOrientationType( DeviceOrientation.landscapeRight, ), ).called(1); @@ -785,7 +784,7 @@ void main() { group('unlockCaptureOrientation', () { setUp(() { when( - () => cameraSettings.mapDeviceOrientationToOrientationType(any()), + () => cameraService.mapDeviceOrientationToOrientationType(any()), ).thenReturn(OrientationType.portraitPrimary); }); @@ -1434,7 +1433,7 @@ void main() { 'with an appropriate view type', (tester) async { final camera = Camera( textureId: cameraId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); // Save the camera in the camera plugin. @@ -1560,7 +1559,7 @@ void main() { testWidgets('returns the correct camera', (tester) async { final camera = Camera( textureId: cameraId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); // Save the camera in the camera plugin. @@ -1599,7 +1598,7 @@ void main() { videoElement = getVideoElementWithBlankStream(videoSize); when( - () => cameraSettings.getMediaStreamForOptions( + () => cameraService.getMediaStreamForOptions( any(), cameraId: cameraId, ), @@ -1607,7 +1606,7 @@ void main() { final camera = Camera( textureId: cameraId, - cameraSettings: cameraSettings, + cameraService: cameraService, ); // Save the camera in the camera plugin. @@ -1963,13 +1962,13 @@ void main() { 'emits a DeviceOrientationChangedEvent ' 'when the screen orientation is changed', (tester) async { when( - () => cameraSettings.mapOrientationTypeToDeviceOrientation( + () => cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.landscapePrimary, ), ).thenReturn(DeviceOrientation.landscapeLeft); when( - () => cameraSettings.mapOrientationTypeToDeviceOrientation( + () => cameraService.mapOrientationTypeToDeviceOrientation( OrientationType.portraitSecondary, ), ).thenReturn(DeviceOrientation.portraitDown); diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 5fa52dd3398d..436f2065aaf5 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -7,7 +7,7 @@ import 'dart:html'; import 'dart:ui'; import 'package:camera_web/src/camera.dart'; -import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:cross_file/cross_file.dart'; import 'package:mocktail/mocktail.dart'; @@ -26,7 +26,7 @@ class MockNavigator extends Mock implements Navigator {} class MockMediaDevices extends Mock implements MediaDevices {} -class MockCameraSettings extends Mock implements CameraSettings {} +class MockCameraService extends Mock implements CameraService {} class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} diff --git a/packages/camera/camera_web/test/types/zoom_level_capability_test.dart b/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart similarity index 80% rename from packages/camera/camera_web/test/types/zoom_level_capability_test.dart rename to packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart index c382b4b76cc4..09de03100871 100644 --- a/packages/camera/camera_web/test/types/zoom_level_capability_test.dart +++ b/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart @@ -4,12 +4,15 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; -import '../helpers/helpers.dart'; +import 'helpers/helpers.dart'; void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('ZoomLevelCapability', () { - test('sets all properties', () { + testWidgets('sets all properties', (tester) async { const minimum = 100.0; const maximum = 400.0; final videoTrack = MockMediaStreamTrack(); @@ -25,7 +28,7 @@ void main() { expect(capability.videoTrack, equals(videoTrack)); }); - test('supports value equality', () { + testWidgets('supports value equality', (tester) async { final videoTrack = MockMediaStreamTrack(); expect( diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index c77d36023058..6f758843a047 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -6,7 +6,7 @@ import 'dart:html' as html; import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; -import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/foundation.dart'; @@ -18,7 +18,7 @@ String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; /// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices /// /// The obtained camera stream is constrained by [options] and fetched -/// with [CameraSettings.getMediaStreamForOptions]. +/// with [CameraService.getMediaStreamForOptions]. /// /// The camera stream is displayed in the [videoElement] wrapped in the /// [divElement] to avoid overriding the custom styles applied to @@ -40,9 +40,9 @@ class Camera { /// [options] and [window]. Camera({ required this.textureId, - required CameraSettings cameraSettings, + required CameraService cameraService, this.options = const CameraOptions(), - }) : _cameraSettings = cameraSettings; + }) : _cameraService = cameraService; // A torch mode constraint name. // See: https://w3c.github.io/mediacapture-image/#dom-mediatracksupportedconstraints-torch @@ -71,8 +71,8 @@ class Camera { @visibleForTesting FlashMode? flashMode; - /// The camera settings used to get the media stream for the camera. - final CameraSettings _cameraSettings; + /// The camera service used to get the media stream for the camera. + final CameraService _cameraService; /// The current browser window used to access media devices. @visibleForTesting @@ -81,7 +81,7 @@ class Camera { /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. Future initialize() async { - stream = await _cameraSettings.getMediaStreamForOptions( + stream = await _cameraService.getMediaStreamForOptions( options, cameraId: textureId, ); @@ -110,7 +110,7 @@ class Camera { /// Initializes the camera source if the camera was previously stopped. Future play() async { if (videoElement.srcObject == null) { - stream = await _cameraSettings.getMediaStreamForOptions( + stream = await _cameraService.getMediaStreamForOptions( options, cameraId: textureId, ); @@ -251,14 +251,14 @@ class Camera { /// Throws a [CameraWebException] if the zoom level is not supported /// or the camera has not been initialized or started. double getMaxZoomLevel() => - _cameraSettings.getZoomLevelCapabilityForCamera(this).maximum; + _cameraService.getZoomLevelCapabilityForCamera(this).maximum; /// Returns the camera minimum zoom level. /// /// Throws a [CameraWebException] if the zoom level is not supported /// or the camera has not been initialized or started. double getMinZoomLevel() => - _cameraSettings.getZoomLevelCapabilityForCamera(this).minimum; + _cameraService.getZoomLevelCapabilityForCamera(this).minimum; /// Sets the camera zoom level to [zoom]. /// @@ -266,7 +266,7 @@ class Camera { /// not supported or the camera has not been initialized or started. void setZoomLevel(double zoom) { final zoomLevelCapability = - _cameraSettings.getZoomLevelCapabilityForCamera(this); + _cameraService.getZoomLevelCapabilityForCamera(this); if (zoom < zoomLevelCapability.minimum || zoom > zoomLevelCapability.maximum) { diff --git a/packages/camera/camera_web/lib/src/camera_settings.dart b/packages/camera/camera_web/lib/src/camera_service.dart similarity index 99% rename from packages/camera/camera_web/lib/src/camera_settings.dart rename to packages/camera/camera_web/lib/src/camera_service.dart index 7d35fff84112..c1a4ad1038ab 100644 --- a/packages/camera/camera_web/lib/src/camera_settings.dart +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -12,9 +12,9 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -/// A utility to fetch, map camera settings and +/// A service to fetch, map camera settings and /// obtain the camera stream. -class CameraSettings { +class CameraService { // A facing mode constraint name. static const _facingModeKey = "facingMode"; diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index ecea3a76e74a..fda33996f474 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -8,7 +8,7 @@ import 'dart:math'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; -import 'package:camera_web/src/camera_settings.dart'; +import 'package:camera_web/src/camera_service.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -25,18 +25,18 @@ const String _kDefaultErrorMessage = /// This class implements the `package:camera` functionality for the web. class CameraPlugin extends CameraPlatform { /// Creates a new instance of [CameraPlugin] - /// with the given [cameraSettings] utility. - CameraPlugin({required CameraSettings cameraSettings}) - : _cameraSettings = cameraSettings; + /// with the given [cameraService]. + CameraPlugin({required CameraService cameraService}) + : _cameraService = cameraService; /// Registers this class as the default instance of [CameraPlatform]. static void registerWith(Registrar registrar) { CameraPlatform.instance = CameraPlugin( - cameraSettings: CameraSettings(), + cameraService: CameraService(), ); } - final CameraSettings _cameraSettings; + final CameraService _cameraService; /// The cameras managed by the [CameraPlugin]. @visibleForTesting @@ -86,7 +86,7 @@ class CameraPlugin extends CameraPlatform { } // Request video and audio permissions. - await _cameraSettings.getMediaStreamForOptions( + await _cameraService.getMediaStreamForOptions( CameraOptions( audio: AudioConstraints(enabled: true), ), @@ -121,13 +121,13 @@ class CameraPlugin extends CameraPlatform { if (videoTracks.isNotEmpty) { // Get the facing mode from the first available video track. final facingMode = - _cameraSettings.getFacingModeForVideoTrack(videoTracks.first); + _cameraService.getFacingModeForVideoTrack(videoTracks.first); // Get the lens direction based on the facing mode. // Fallback to the external lens direction // if the facing mode is not available. final lensDirection = facingMode != null - ? _cameraSettings.mapFacingModeToLensDirection(facingMode) + ? _cameraService.mapFacingModeToLensDirection(facingMode) : CameraLensDirection.external; // Create a camera description. @@ -191,20 +191,19 @@ class CameraPlugin extends CameraPlatform { final cameraMetadata = camerasMetadata[cameraDescription]!; final cameraType = cameraMetadata.facingMode != null - ? _cameraSettings - .mapFacingModeToCameraType(cameraMetadata.facingMode!) + ? _cameraService.mapFacingModeToCameraType(cameraMetadata.facingMode!) : null; // Use the highest resolution possible // if the resolution preset is not specified. - final videoSize = _cameraSettings + final videoSize = _cameraService .mapResolutionPresetToSize(resolutionPreset ?? ResolutionPreset.max); // Create a camera with the given audio and video constraints. // Sensor orientation is currently not supported. final camera = Camera( textureId: textureId, - cameraSettings: _cameraSettings, + cameraService: _cameraService, options: CameraOptions( audio: AudioConstraints(enabled: enableAudio), video: VideoConstraints( @@ -334,7 +333,7 @@ class CameraPlugin extends CameraPlatform { if (orientation != null) { return orientation.onChange.map( (html.Event _) { - final deviceOrientation = _cameraSettings + final deviceOrientation = _cameraService .mapOrientationTypeToDeviceOrientation(orientation.type!); return DeviceOrientationChangedEvent(deviceOrientation); }, @@ -354,7 +353,7 @@ class CameraPlugin extends CameraPlatform { final documentElement = window?.document.documentElement; if (orientation != null && documentElement != null) { - final orientationType = _cameraSettings + final orientationType = _cameraService .mapDeviceOrientationToOrientationType(deviceOrientation); // Full-screen mode may be required to modify the device orientation. @@ -549,7 +548,7 @@ class CameraPlugin extends CameraPlatform { video: VideoConstraints(deviceId: deviceId), ); - return _cameraSettings.getMediaStreamForOptions(cameraOptions); + return _cameraService.getMediaStreamForOptions(cameraOptions); } /// Returns a camera for the given [cameraId]. diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index ec674f375164..a2aa43c22d65 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -30,5 +30,4 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - mocktail: ^0.1.4 pedantic: ^1.11.1 \ No newline at end of file diff --git a/packages/camera/camera_web/test/helpers/helpers.dart b/packages/camera/camera_web/test/helpers/helpers.dart deleted file mode 100644 index 7094f55bb62e..000000000000 --- a/packages/camera/camera_web/test/helpers/helpers.dart +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright 2013 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. - -export 'mocks.dart'; diff --git a/packages/camera/camera_web/test/helpers/mocks.dart b/packages/camera/camera_web/test/helpers/mocks.dart deleted file mode 100644 index 34c56632b60f..000000000000 --- a/packages/camera/camera_web/test/helpers/mocks.dart +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2013 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:html'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} - -/// A fake [MediaError] that returns the provided error [_code]. -class FakeMediaError extends Fake implements MediaError { - FakeMediaError(this._code); - - final int _code; - - @override - int get code => _code; -} From d6ba532344a62b19026a8d4f25a70fc9bdc10e95 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 12 Aug 2021 02:22:05 +0200 Subject: [PATCH 190/364] fix: don't request full-screen mode in unlockCaptureOrientation (#4226) --- .../example/integration_test/camera_web_test.dart | 10 ---------- packages/camera/camera_web/lib/src/camera_web.dart | 3 --- 2 files changed, 13 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index ada5c2da1a29..555e20040a3c 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -788,16 +788,6 @@ void main() { ).thenReturn(OrientationType.portraitPrimary); }); - testWidgets( - 'requests full-screen mode ' - 'on documentElement', (tester) async { - await CameraPlatform.instance.unlockCaptureOrientation( - cameraId, - ); - - verify(documentElement.requestFullscreen).called(1); - }); - testWidgets('unlocks the capture orientation', (tester) async { await CameraPlatform.instance.unlockCaptureOrientation( cameraId, diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index fda33996f474..1038d227e23e 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -378,9 +378,6 @@ class CameraPlugin extends CameraPlatform { final documentElement = window?.document.documentElement; if (orientation != null && documentElement != null) { - // Full-screen mode may be required to modify the device orientation. - // See: https://w3c.github.io/screen-orientation/#interaction-with-fullscreen-api - documentElement.requestFullscreen(); orientation.unlock(); } else { throw PlatformException( From 1ac46e159c5d560df0cb716b36e622ad4f8f188c Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 12 Aug 2021 02:24:52 +0200 Subject: [PATCH 191/364] [camera_web] docs: add `setFlashMode` comments (#4227) --- packages/camera/camera_web/lib/src/camera.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 6f758843a047..237f9858855e 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -133,8 +133,8 @@ class Camera { /// Captures a picture and returns the saved file in a JPEG format. /// - /// Enables the device flash when taking a picture if the flash mode - /// is either [FlashMode.auto] or [FlashMode.always]. + /// Enables the camera flash (torch mode) for a period of taking a picture + /// if the flash mode is either [FlashMode.auto] or [FlashMode.always]. Future takePicture() async { final shouldEnableTorchMode = flashMode == FlashMode.auto || flashMode == FlashMode.always; @@ -185,7 +185,14 @@ class Camera { } } - /// Sets the camera flash mode to [mode]. + /// Sets the camera flash mode to [mode] by modifying the camera + /// torch mode constraint. + /// + /// The torch mode is enabled for [FlashMode.torch] and + /// disabled for [FlashMode.off]. + /// + /// For [FlashMode.auto] and [FlashMode.always] the torch mode is enabled + /// only for a period of taking a picture in [takePicture]. /// /// Throws a [CameraWebException] if the torch mode is not supported /// or the camera has not been initialized or started. From 4383bb15d91384efb22dbe409c3fd58e4552267c Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 12 Aug 2021 02:27:05 +0200 Subject: [PATCH 192/364] [camera_web] Handle camera errors in `takePicture` (#4230) --- .../integration_test/camera_web_test.dart | 62 +++++++++++++++++++ .../camera/camera_web/lib/src/camera_web.dart | 3 + 2 files changed, 65 insertions(+) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 555e20040a3c..57c3de83ba04 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -929,6 +929,32 @@ void main() { ), ); }); + + testWidgets('when takePicture throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.takePicture).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.takePicture(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); }); }); @@ -1763,6 +1789,42 @@ void main() { await streamQueue.cancel(); }); + testWidgets( + 'emits a CameraErrorEvent ' + 'on takePicture error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.takePicture).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => await CameraPlatform.instance.takePicture(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + testWidgets( 'emits a CameraErrorEvent ' 'on setFlashMode error', (tester) async { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 1038d227e23e..134e0726ba4b 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -396,6 +396,9 @@ class CameraPlugin extends CameraPlatform { return getCamera(cameraId).takePicture(); } on html.DomException catch (e) { throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); } } From e5fc83d2b9024802e3e7712854b9789d7cbcee3e Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 13 Aug 2021 01:40:58 +0200 Subject: [PATCH 193/364] [camera_web] Fix `getCapabilities` not supported error thrown when selecting a camera on Firefox (#4234) * feat: check if getCapabilities can be called rather than catching an exception --- .../integration_test/camera_service_test.dart | 86 ++++++++----------- .../integration_test/helpers/mocks.dart | 3 + .../camera_web/lib/src/camera_service.dart | 68 +++++++-------- .../lib/src/shims/dart_js_util.dart | 14 +++ 4 files changed, 85 insertions(+), 86 deletions(-) create mode 100644 packages/camera/camera_web/lib/src/shims/dart_js_util.dart diff --git a/packages/camera/camera_web/example/integration_test/camera_service_test.dart b/packages/camera/camera_web/example/integration_test/camera_service_test.dart index 161aeb4a595e..937f023f4b36 100644 --- a/packages/camera/camera_web/example/integration_test/camera_service_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_service_test.dart @@ -9,6 +9,7 @@ import 'dart:js_util' as js_util; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_service.dart'; +import 'package:camera_web/src/shims/dart_js_util.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -27,15 +28,25 @@ void main() { late Navigator navigator; late MediaDevices mediaDevices; late CameraService cameraService; + late JsUtil jsUtil; setUp(() async { window = MockWindow(); navigator = MockNavigator(); mediaDevices = MockMediaDevices(); + jsUtil = MockJsUtil(); when(() => window.navigator).thenReturn(navigator); when(() => navigator.mediaDevices).thenReturn(mediaDevices); + // Mock JsUtil to return the real getProperty from dart:js_util. + when(() => jsUtil.getProperty(any(), any())).thenAnswer( + (invocation) => js_util.getProperty( + invocation.positionalArguments[0], + invocation.positionalArguments[1], + ), + ); + cameraService = CameraService()..window = window; }); @@ -354,6 +365,8 @@ void main() { when(() => camera.textureId).thenReturn(0); when(() => camera.stream).thenReturn(FakeMediaStream(videoTracks)); + + cameraService.jsUtil = jsUtil; }); testWidgets( @@ -496,6 +509,10 @@ void main() { }); group('getFacingModeForVideoTrack', () { + setUp(() { + cameraService.jsUtil = jsUtil; + }); + testWidgets( 'throws PlatformException ' 'with notSupported error ' @@ -525,14 +542,18 @@ void main() { final facingMode = cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()); - expect( - facingMode, - equals(null), - ); + expect(facingMode, isNull); }); group('when the facing mode is supported', () { + late MediaStreamTrack videoTrack; + setUp(() { + videoTrack = MockMediaStreamTrack(); + + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + .thenReturn(true); + when(mediaDevices.getSupportedConstraints).thenReturn({ 'facingMode': true, }); @@ -541,95 +562,58 @@ void main() { testWidgets( 'returns an appropriate facing mode ' 'based on the video track settings', (tester) async { - final videoTrack = MockMediaStreamTrack(); - when(videoTrack.getSettings).thenReturn({'facingMode': 'user'}); final facingMode = cameraService.getFacingModeForVideoTrack(videoTrack); - expect( - facingMode, - equals('user'), - ); + expect(facingMode, equals('user')); }); testWidgets( 'returns an appropriate facing mode ' 'based on the video track capabilities ' 'when the facing mode setting is empty', (tester) async { - final videoTrack = MockMediaStreamTrack(); - when(videoTrack.getSettings).thenReturn({}); when(videoTrack.getCapabilities).thenReturn({ 'facingMode': ['environment', 'left'] }); + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + .thenReturn(true); + final facingMode = cameraService.getFacingModeForVideoTrack(videoTrack); - expect( - facingMode, - equals('environment'), - ); + expect(facingMode, equals('environment')); }); testWidgets( 'returns null ' 'when the facing mode setting ' 'and capabilities are empty', (tester) async { - final videoTrack = MockMediaStreamTrack(); - when(videoTrack.getSettings).thenReturn({}); when(videoTrack.getCapabilities).thenReturn({'facingMode': []}); final facingMode = cameraService.getFacingModeForVideoTrack(videoTrack); - expect( - facingMode, - equals(null), - ); + expect(facingMode, isNull); }); testWidgets( 'returns null ' 'when the facing mode setting is empty and ' 'the video track capabilities are not supported', (tester) async { - final videoTrack = MockMediaStreamTrack(); - when(videoTrack.getSettings).thenReturn({}); - when(videoTrack.getCapabilities).thenThrow(JSNoSuchMethodError()); + + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + .thenReturn(false); final facingMode = cameraService.getFacingModeForVideoTrack(videoTrack); - expect( - facingMode, - equals(null), - ); - }); - - testWidgets( - 'throws PlatformException ' - 'with unknown error ' - 'when getting the video track capabilities ' - 'throws an unknown error', (tester) async { - final videoTrack = MockMediaStreamTrack(); - - when(videoTrack.getSettings).thenReturn({}); - when(videoTrack.getCapabilities).thenThrow(Exception('Unknown')); - - expect( - () => cameraService.getFacingModeForVideoTrack(videoTrack), - throwsA( - isA().having( - (e) => e.code, - 'code', - CameraErrorCode.unknown.toString(), - ), - ), - ); + expect(facingMode, isNull); }); }); }); diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 436f2065aaf5..e6a11cc0b454 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -8,6 +8,7 @@ import 'dart:ui'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_service.dart'; +import 'package:camera_web/src/shims/dart_js_util.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:cross_file/cross_file.dart'; import 'package:mocktail/mocktail.dart'; @@ -38,6 +39,8 @@ class MockVideoElement extends Mock implements VideoElement {} class MockXFile extends Mock implements XFile {} +class MockJsUtil extends Mock implements JsUtil {} + /// A fake [MediaStream] that returns the provided [_videoTracks]. class FakeMediaStream extends Fake implements MediaStream { FakeMediaStream(this._videoTracks); diff --git a/packages/camera/camera_web/lib/src/camera_service.dart b/packages/camera/camera_web/lib/src/camera_service.dart index c1a4ad1038ab..612b2b138fdb 100644 --- a/packages/camera/camera_web/lib/src/camera_service.dart +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -4,10 +4,10 @@ import 'dart:html' as html; import 'dart:ui'; -import 'dart:js_util' as js_util; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; +import 'package:camera_web/src/shims/dart_js_util.dart'; import 'package:camera_web/src/types/types.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -22,6 +22,10 @@ class CameraService { @visibleForTesting html.Window? window = html.window; + /// The utility to manipulate JavaScript interop objects. + @visibleForTesting + JsUtil jsUtil = JsUtil(); + /// Returns a media stream associated with the camera device /// with [cameraId] and constrained by [options]. Future getMediaStreamForOptions( @@ -143,8 +147,8 @@ class CameraService { // The zoom level capability is a nested JS object, therefore // we need to access its properties with the js_util library. // See: https://api.dart.dev/stable/2.13.4/dart-js_util/getProperty.html - final minimumZoomLevel = js_util.getProperty(zoomLevelCapability, 'min'); - final maximumZoomLevel = js_util.getProperty(zoomLevelCapability, 'max'); + final minimumZoomLevel = jsUtil.getProperty(zoomLevelCapability, 'min'); + final maximumZoomLevel = jsUtil.getProperty(zoomLevelCapability, 'max'); if (minimumZoomLevel != null && maximumZoomLevel != null) { return ZoomLevelCapability( @@ -201,40 +205,34 @@ class CameraService { final facingMode = videoTrackSettings[_facingModeKey]; if (facingMode == null) { - try { - // If the facing mode does not exist in the video track settings, - // check for the facing mode in the video track capabilities. - // - // MediaTrackCapabilities: - // https://www.w3.org/TR/mediacapture-streams/#dom-mediatrackcapabilities - // - // This may throw a not supported error on Firefox. - final videoTrackCapabilities = videoTrack.getCapabilities(); + // If the facing mode does not exist in the video track settings, + // check for the facing mode in the video track capabilities. + // + // MediaTrackCapabilities: + // https://www.w3.org/TR/mediacapture-streams/#dom-mediatrackcapabilities + + // Check if getting the video track capabilities is supported. + // + // The method may not be supported on Firefox. + // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getCapabilities#browser_compatibility + if (!jsUtil.hasProperty(videoTrack, 'getCapabilities')) { + // Return null if the video track capabilites are not supported. + return null; + } + + final videoTrackCapabilities = videoTrack.getCapabilities(); - // A list of facing mode capabilities as - // the camera may support multiple facing modes. - final facingModeCapabilities = - List.from(videoTrackCapabilities[_facingModeKey] ?? []); + // A list of facing mode capabilities as + // the camera may support multiple facing modes. + final facingModeCapabilities = + List.from(videoTrackCapabilities[_facingModeKey] ?? []); - if (facingModeCapabilities.isNotEmpty) { - final facingModeCapability = facingModeCapabilities.first; - return facingModeCapability; - } else { - // Return null if there are no facing mode capabilities. - return null; - } - } catch (e) { - switch (e.runtimeType.toString()) { - case 'JSNoSuchMethodError': - // Return null if getting capabilities is currently not supported. - return null; - default: - throw PlatformException( - code: CameraErrorCode.unknown.toString(), - message: - 'An unknown error occured when getting the video track capabilities.', - ); - } + if (facingModeCapabilities.isNotEmpty) { + final facingModeCapability = facingModeCapabilities.first; + return facingModeCapability; + } else { + // Return null if there are no facing mode capabilities. + return null; } } diff --git a/packages/camera/camera_web/lib/src/shims/dart_js_util.dart b/packages/camera/camera_web/lib/src/shims/dart_js_util.dart new file mode 100644 index 000000000000..6601bec6f529 --- /dev/null +++ b/packages/camera/camera_web/lib/src/shims/dart_js_util.dart @@ -0,0 +1,14 @@ +// Copyright 2013 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:js_util' as js_util; + +/// A utility that shims dart:js_util to manipulate JavaScript interop objects. +class JsUtil { + /// Returns true if the object [o] has the property [name]. + bool hasProperty(Object o, Object name) => js_util.hasProperty(o, name); + + /// Returns the value of the property [name] in the object [o]. + dynamic getProperty(Object o, Object name) => js_util.getProperty(o, name); +} From 1e497d62039b8d668bb82c2bdde76ecb9e1a6af1 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 13 Aug 2021 01:41:19 +0200 Subject: [PATCH 194/364] [camera_web] Add missing setFlashMode test (#4235) --- .../example/integration_test/camera_web_test.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 57c3de83ba04..6b03b6d77035 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -998,6 +998,21 @@ void main() { }); group('setFlashMode', () { + testWidgets('calls setFlashMode on the camera', (tester) async { + final camera = MockCamera(); + const flashMode = FlashMode.always; + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.setFlashMode( + cameraId, + flashMode, + ); + + verify(() => camera.setFlashMode(flashMode)).called(1); + }); + group('throws PlatformException', () { testWidgets( 'with notFound error ' From b1c65df43c5a0e664ebc75eabd78b16b49f51c39 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 13 Aug 2021 01:41:43 +0200 Subject: [PATCH 195/364] [camera_web] Update the web plugin README (#4237) * docs: update web plugin README * docs: update web plugin missing implementation README --- packages/camera/camera_web/README.md | 76 ++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md index d57bd7446d17..8c216b3f4e0e 100644 --- a/packages/camera/camera_web/README.md +++ b/packages/camera/camera_web/README.md @@ -1,7 +1,77 @@ # Camera Web Plugin -A Flutter plugin for Web allowing access to the device cameras. +The web implementation of [`camera`][camera]. -*Note*: This plugin is under development. +*Note*: This plugin is under development. See [missing implementation](#missing-implementation). -In order to use this plugin, your app should depend both on `camera` and `camera_web`. This is a temporary solution until a plugin is released. \ No newline at end of file +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), which means you can simply use `camera` normally. This package will be automatically included in your app when you do. + +## Example + +Find the example in the [`camera` package](https://pub.dev/packages/camera#example). + +## Limitations on the web platform + +### Camera devices + +The camera devices are accessed with [Stream Web API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Streams_API) with the following [browser support](https://caniuse.com/stream): + +![Data on support for the Stream feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/stream.png) + +Accessing camera devices requires a [secure browsing context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). This means that you might need to serve your web application over HTTPS. For insecure contexts `CameraPlatform.availableCameras` might throw a `CameraException` with the `permissionDenied` error code. + +### Device orientation + +The device orientation implementation is backed by [`Screen Orientation Web API`](https://www.w3.org/TR/screen-orientation/) with the following [browser support](https://caniuse.com/screen-orientation): + +![Data on support for the Screen Orientation feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/screen-orientation.png) + +For the browsers that do not support the device orientation: +- `CameraPlatform.onDeviceOrientationChanged` returns an empty stream. +- `CameraPlatform.lockCaptureOrientation` and `CameraPlatform.unlockCaptureOrientation` throw a `PlatformException` with the `orientationNotSupported` error code. + +### Flash mode and zoom level + +The flash mode and zoom level implementation is backed by [Image Capture Web API](https://w3c.github.io/mediacapture-image/) with the following [browser support](https://caniuse.com/mdn-api_imagecapture) (as of 12 August 2021): + +![Data on support for the Image Capture feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/static/v1/mdn-api__ImageCapture-1628778966589.png) + +For the browsers that do not support the flash mode: +- `CameraPlatform.setFlashMode` throws a `PlatformException` with the `torchModeNotSupported` error code. + +For the browsers that do not support the zoom level: +- `CameraPlatform.getMaxZoomLevel`, `CameraPlatform.getMinZoomLevel` and `CameraPlatform.setZoomLevel` throw a `PlatformException` with the `zoomLevelNotSupported` error code. + +### Taking a picture + +The image capturing implementation is backed by [`URL.createObjectUrl` Web API](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL) with the following [browser support](https://caniuse.com/bloburls): + +![Data on support for the Blob URLs feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/bloburls.png) + +The web platform does not support `dart:io`. Attempts to display a captured image using `Image.file` will throw an error. The capture image contains a network-accessible URL pointing to a location within the browser and should be displayed using `Image.network` or `Image.memory` after loading the image bytes to memory. + +See the example below: + +```dart +if (kIsWeb) { + Image.network(capturedImage.path); +} else { + Image.file(File(capturedImage.path)); +} +``` + +## Missing implementation + +The web implementation of [`camera`][camera] is missing the following features: +- Video recording +- Exposure mode, point and offset +- Focus mode and point +- Camera closing events +- Camera sensor orientation +- Camera image format group +- Camera image streaming + + +[camera]: https://pub.dev/packages/camera \ No newline at end of file From c8570fc681d83c1086f30ecf6843a958f63323ba Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 13 Aug 2021 08:52:06 +0200 Subject: [PATCH 196/364] fix: disposed CameraController error thrown when changing a camera (#4236) --- packages/camera/camera/example/lib/main.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 00ac2251ba2a..2314aecbece3 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -594,17 +594,21 @@ class _CameraExampleHomeState extends State } void onNewCameraSelected(CameraDescription cameraDescription) async { - if (controller != null) { - await controller!.dispose(); - } + final previousCameraController = controller; + final CameraController cameraController = CameraController( cameraDescription, ResolutionPreset.medium, enableAudio: enableAudio, imageFormatGroup: ImageFormatGroup.jpeg, ); + controller = cameraController; + if (mounted) { + setState(() {}); + } + // If the controller is updated then update the UI. cameraController.addListener(() { if (mounted) setState(() {}); @@ -637,6 +641,8 @@ class _CameraExampleHomeState extends State if (mounted) { setState(() {}); } + + await previousCameraController?.dispose(); } void onTakePictureButtonPressed() { From ebf4d59543a998d25c90b4a3f72c2eb42d628b7c Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Sat, 14 Aug 2021 01:04:33 +0200 Subject: [PATCH 197/364] [camera_web] Add support for pausing and resuming the camera preview (#4239) * chore: update camera_platform_interface to 2.1.0 * feat: add pause to Camera * test: add Camera pause test * feat: add pausePreview and resumePreview implementation * test: add pausePreview and resumePreview tests --- .../example/integration_test/camera_test.dart | 18 ++ .../integration_test/camera_web_test.dart | 165 ++++++++++++++++++ .../camera/camera_web/lib/src/camera.dart | 5 + .../camera/camera_web/lib/src/camera_web.dart | 21 +++ packages/camera/camera_web/pubspec.yaml | 2 +- 5 files changed, 210 insertions(+), 1 deletion(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 5c3d842502ba..1d1659352f26 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -214,6 +214,24 @@ void main() { }); }); + group('pause', () { + testWidgets('pauses the camera stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.play(); + + expect(camera.videoElement.paused, isFalse); + + camera.pause(); + + expect(camera.videoElement.paused, isTrue); + }); + }); + group('stop', () { testWidgets('resets the camera stream', (tester) async { final camera = Camera( diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 6b03b6d77035..d48df122277f 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -1459,6 +1459,135 @@ void main() { }); }); + group('pausePreview', () { + testWidgets('calls pause on the camera', (tester) async { + final camera = MockCamera(); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.pausePreview(cameraId); + + verify(camera.pause).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () async => await CameraPlatform.instance.pausePreview(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when pause throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(camera.pause).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.pausePreview(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + }); + }); + + group('resumePreview', () { + testWidgets('calls play on the camera', (tester) async { + final camera = MockCamera(); + + when(camera.play).thenAnswer((_) async => {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.resumePreview(cameraId); + + verify(camera.play).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () async => await CameraPlatform.instance.resumePreview(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when play throws DomException', (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.NOT_SUPPORTED); + + when(camera.play).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.resumePreview(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when play throws CameraWebException', (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.unknown, + 'description', + ); + + when(camera.play).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () async => await CameraPlatform.instance.resumePreview(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); + }); + testWidgets( 'buildPreview returns an HtmlElementView ' 'with an appropriate view type', (tester) async { @@ -1993,6 +2122,42 @@ void main() { await streamQueue.cancel(); }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on resumePreview error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.unknown, + 'description', + ); + + when(camera.play).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => await CameraPlatform.instance.resumePreview(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); }); testWidgets('onVideoRecordedEvent throws UnimplementedError', diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 237f9858855e..c1343ceccf49 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -119,6 +119,11 @@ class Camera { await videoElement.play(); } + /// Pauses the camera stream on the current frame. + void pause() async { + videoElement.pause(); + } + /// Stops the camera stream and resets the camera source. void stop() { final tracks = videoElement.srcObject?.getTracks(); diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 134e0726ba4b..8b131f5d4f6e 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -517,6 +517,27 @@ class CameraPlugin extends CameraPlatform { } } + @override + Future pausePreview(int cameraId) async { + try { + getCamera(cameraId).pause(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } + } + + @override + Future resumePreview(int cameraId) async { + try { + await getCamera(cameraId).play(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } + } + @override Widget buildPreview(int cameraId) { return HtmlElementView( diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index a2aa43c22d65..c4d78999f273 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -21,7 +21,7 @@ flutter: fileName: camera_web.dart dependencies: - camera_platform_interface: ^2.0.1 + camera_platform_interface: ^2.1.0 flutter: sdk: flutter flutter_web_plugins: From 0209a2fadefe2950ffaaac5aa184ccb1d2a18f3b Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 13 Aug 2021 16:22:24 -0700 Subject: [PATCH 198/364] Eliminate build_all_plugins_app.sh (#4232) Removes the `build_all_plugins_app.sh` bash script, in support of the goal of eliminating all use of bash from the repository (for maintainability, and for better Windows compatibility). - The exclusion list moves to a config file, match other recent repo changes - The exclusion logging moves into the tool itself, consistent with the tool doing more logging of skipped and excluded plugins - The bulk of the logic moves to a Cirrus task template. This was done instead of rewriting the script in Dart, even though it will mean more work for alternate CI support (e.g., bringing this up on a Windows LUCI bot), because breaking it into components makes it easier to pinpoint failures from the CI UI rather than having all the steps smashed together. --- .cirrus.yml | 36 ++++++--- script/build_all_plugins_app.sh | 73 ------------------- script/common.sh | 14 ---- script/configs/exclude_all_plugins_app.yaml | 10 +++ .../tool/lib/src/common/plugin_command.dart | 4 +- .../src/create_all_plugins_app_command.dart | 28 +++++-- .../create_all_plugins_app_command_test.dart | 42 +++++++++-- script/tool_runner.sh | 6 +- 8 files changed, 99 insertions(+), 114 deletions(-) delete mode 100755 script/build_all_plugins_app.sh delete mode 100644 script/common.sh create mode 100644 script/configs/exclude_all_plugins_app.yaml diff --git a/.cirrus.yml b/.cirrus.yml index f978cc729799..ffdd71daebc4 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -28,6 +28,20 @@ flutter_upgrade_template: &FLUTTER_UPGRADE_TEMPLATE - flutter doctor -v << : *TOOL_SETUP_TEMPLATE +build_all_plugins_app_template: &BUILD_ALL_PLUGINS_APP_TEMPLATE + create_all_plugins_app_script: + - dart $PLUGIN_TOOL all-plugins-app --output-dir=. --exclude script/configs/exclude_all_plugins_app.yaml + build_all_plugins_debug_script: + - cd all_plugins + - if [[ "$BUILD_ALL_ARGS" == "web" ]]; then + - echo "Skipping; web does not support debug builds" + - else + - flutter build $BUILD_ALL_ARGS --debug + - fi + build_all_plugins_release_script: + - cd all_plugins + - flutter build $BUILD_ALL_ARGS --release + macos_template: &MACOS_TEMPLATE # Only one macOS task can run in parallel without credits, so use them for # PRs on macOS. @@ -82,28 +96,29 @@ task: ### Android tasks ### - name: build_all_plugins_apk env: + BUILD_ALL_ARGS: "apk" matrix: CHANNEL: "master" CHANNEL: "stable" - script: - - ./script/build_all_plugins_app.sh apk + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE ### Web tasks ### - name: build_all_plugins_web env: + BUILD_ALL_ARGS: "web" matrix: CHANNEL: "master" CHANNEL: "stable" - script: - - ./script/build_all_plugins_app.sh web + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE ### Linux desktop tasks ### - name: build_all_plugins_linux env: + BUILD_ALL_ARGS: "linux" matrix: CHANNEL: "master" CHANNEL: "stable" - script: + setup_script: - flutter config --enable-linux-desktop - - ./script/build_all_plugins_app.sh linux + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - name: build-linux+drive-examples env: matrix: @@ -200,11 +215,11 @@ task: ### iOS tasks ### - name: build_all_plugins_ipa env: + BUILD_ALL_ARGS: "ios --no-codesign" matrix: CHANNEL: "master" CHANNEL: "stable" - script: - - ./script/build_all_plugins_app.sh ios --no-codesign + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - name: build-ipas+drive-examples env: PATH: $PATH:/usr/local/bin @@ -234,12 +249,13 @@ task: ### macOS desktop tasks ### - name: build_all_plugins_macos env: + BUILD_ALL_ARGS: "macos" matrix: CHANNEL: "master" CHANNEL: "stable" - script: + setup_script: - flutter config --enable-macos-desktop - - ./script/build_all_plugins_app.sh macos + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - name: build-macos+drive-examples env: matrix: diff --git a/script/build_all_plugins_app.sh b/script/build_all_plugins_app.sh deleted file mode 100755 index 3b3416021a42..000000000000 --- a/script/build_all_plugins_app.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# Copyright 2013 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. - -# Usage: -# -# ./script/build_all_plugins_app.sh apk -# ./script/build_all_plugins_app.sh ios - -# This script builds the app in flutter/plugins/example/all_plugins to make -# sure all first party plugins can be compiled together. - -# So that users can run this script from anywhere and it will work as expected. -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null && pwd)" - -readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" - -source "$SCRIPT_DIR/common.sh" - -# This list should be kept as short as possible, and things should remain here -# only as long as necessary, since in general the goal is for all of the latest -# versions of plugins to be mutually compatible. -# -# An example use case for this list would be to temporarily add plugins while -# updating multiple plugins for a breaking change in a common dependency in -# cases where using a relaxed version constraint isn't possible. -readonly EXCLUDED_PLUGINS_LIST=( - "plugin_platform_interface" # This should never be a direct app dependency. -) -# Comma-separated string of the list above -readonly EXCLUDED=$(IFS=, ; echo "${EXCLUDED_PLUGINS_LIST[*]}") - -ALL_EXCLUDED=($EXCLUDED) - -echo "Excluding the following plugins: $ALL_EXCLUDED" - -(cd "$REPO_DIR" && plugin_tools all-plugins-app --exclude $ALL_EXCLUDED) - -# Master now creates null-safe app code by default; migrate stable so both -# branches are building in the same mode. -if [[ "${CHANNEL}" == "stable" ]]; then - (cd $REPO_DIR/all_plugins && dart migrate --apply-changes) -fi - -function error() { - echo "$@" 1>&2 -} - -failures=0 - -BUILD_MODES=("debug" "release") -# Web doesn't support --debug for builds. -if [[ "$1" == "web" ]]; then - BUILD_MODES=("release") -fi - -for version in "${BUILD_MODES[@]}"; do - echo "Building $version..." - (cd $REPO_DIR/all_plugins && flutter build $@ --$version) - - if [ $? -eq 0 ]; then - echo "Successfully built $version all_plugins app." - echo "All first-party plugins compile together." - else - error "Failed to build $version all_plugins app." - error "This indicates a conflict between two or more first-party plugins." - failures=$(($failures + 1)) - fi -done - -rm -rf $REPO_DIR/all_plugins/ -exit $failures diff --git a/script/common.sh b/script/common.sh deleted file mode 100644 index 11eb64101f2b..000000000000 --- a/script/common.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# Copyright 2013 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. - -function error() { - echo "$@" 1>&2 -} - -# Runs the plugin tools from the plugin_tools git submodule. -function plugin_tools() { - (pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null - dart run "$REPO_DIR/script/tool/bin/flutter_plugin_tools.dart" "$@" -} diff --git a/script/configs/exclude_all_plugins_app.yaml b/script/configs/exclude_all_plugins_app.yaml new file mode 100644 index 000000000000..8dd0fde5ef5f --- /dev/null +++ b/script/configs/exclude_all_plugins_app.yaml @@ -0,0 +1,10 @@ +# This list should be kept as short as possible, and things should remain here +# only as long as necessary, since in general the goal is for all of the latest +# versions of plugins to be mutually compatible. +# +# An example use case for this list would be to temporarily add plugins while +# updating multiple plugins for a breaking change in a common dependency in +# cases where using a relaxed version constraint isn't possible. + +# This is a permament entry, as it should never be a direct app dependency. +- plugin_platform_interface diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index db0a821fd2d7..10f423360878 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -191,7 +191,7 @@ abstract class PluginCommand extends Command { } /// Returns the set of plugins to exclude based on the `--exclude` argument. - Set _getExcludedPackageName() { + Set getExcludedPackageNames() { final Set excludedPackages = _excludedPackages ?? getStringListArg(_excludeArg).expand((String item) { if (item.endsWith('.yaml')) { @@ -265,7 +265,7 @@ abstract class PluginCommand extends Command { Stream _getAllPackages() async* { Set plugins = Set.from(getStringListArg(_packagesArg)); - final Set excludedPluginNames = _getExcludedPackageName(); + final Set excludedPluginNames = getExcludedPackageNames(); final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); if (plugins.isEmpty && diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index d4eccb8a313e..e1cee6f3fe7d 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -12,22 +12,27 @@ import 'package:pubspec_parse/pubspec_parse.dart'; import 'common/core.dart'; import 'common/plugin_command.dart'; +const String _outputDirectoryFlag = 'output-dir'; + /// A command to create an application that builds all in a single application. class CreateAllPluginsAppCommand extends PluginCommand { /// Creates an instance of the builder command. CreateAllPluginsAppCommand( Directory packagesDir, { Directory? pluginsRoot, - }) : pluginsRoot = pluginsRoot ?? packagesDir.fileSystem.currentDirectory, - super(packagesDir) { - appDirectory = this.pluginsRoot.childDirectory('all_plugins'); + }) : super(packagesDir) { + final Directory defaultDir = + pluginsRoot ?? packagesDir.fileSystem.currentDirectory; + argParser.addOption(_outputDirectoryFlag, + defaultsTo: defaultDir.path, + help: 'The path the directory to create the "all_plugins" project in.\n' + 'Defaults to the repository root.'); } - /// The root directory of the plugin repository. - Directory pluginsRoot; - /// The location of the synthesized app project. - late Directory appDirectory; + Directory get appDirectory => packagesDir.fileSystem + .directory(getStringArg(_outputDirectoryFlag)) + .childDirectory('all_plugins'); @override String get description => @@ -43,6 +48,15 @@ class CreateAllPluginsAppCommand extends PluginCommand { throw ToolExit(exitCode); } + final Set excluded = getExcludedPackageNames(); + if (excluded.isNotEmpty) { + print('Exluding the following plugins from the combined build:'); + for (final String plugin in excluded) { + print(' $plugin'); + } + print(''); + } + await Future.wait(>[ _genPubspecWithAllPlugins(), _updateAppGradle(), diff --git a/script/tool/test/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index 073024a17bb3..4439d13c3625 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -13,10 +13,10 @@ import 'util.dart'; void main() { group('$CreateAllPluginsAppCommand', () { late CommandRunner runner; - FileSystem fileSystem; + late CreateAllPluginsAppCommand command; + late FileSystem fileSystem; late Directory testRoot; late Directory packagesDir; - late Directory appDir; setUp(() { // Since the core of this command is a call to 'flutter create', the test @@ -26,11 +26,10 @@ void main() { testRoot = fileSystem.systemTempDirectory.createTempSync(); packagesDir = testRoot.childDirectory('packages'); - final CreateAllPluginsAppCommand command = CreateAllPluginsAppCommand( + command = CreateAllPluginsAppCommand( packagesDir, pluginsRoot: testRoot, ); - appDir = command.appDirectory; runner = CommandRunner( 'create_all_test', 'Test for $CreateAllPluginsAppCommand'); runner.addCommand(command); @@ -47,7 +46,7 @@ void main() { await runCapturingPrint(runner, ['all-plugins-app']); final List pubspec = - appDir.childFile('pubspec.yaml').readAsLinesSync(); + command.appDirectory.childFile('pubspec.yaml').readAsLinesSync(); expect( pubspec, @@ -65,7 +64,7 @@ void main() { await runCapturingPrint(runner, ['all-plugins-app']); final List pubspec = - appDir.childFile('pubspec.yaml').readAsLinesSync(); + command.appDirectory.childFile('pubspec.yaml').readAsLinesSync(); expect( pubspec, @@ -82,9 +81,38 @@ void main() { await runCapturingPrint(runner, ['all-plugins-app']); final String pubspec = - appDir.childFile('pubspec.yaml').readAsStringSync(); + command.appDirectory.childFile('pubspec.yaml').readAsStringSync(); expect(pubspec, contains(RegExp('sdk:\\s*(?:["\']>=|[^])2\\.12\\.'))); }); + + test('handles --output-dir', () async { + createFakePlugin('plugina', packagesDir); + + final Directory customOutputDir = + fileSystem.systemTempDirectory.createTempSync(); + await runCapturingPrint(runner, + ['all-plugins-app', '--output-dir=${customOutputDir.path}']); + + expect(command.appDirectory.path, + customOutputDir.childDirectory('all_plugins').path); + }); + + test('logs exclusions', () async { + createFakePlugin('plugina', packagesDir); + createFakePlugin('pluginb', packagesDir); + createFakePlugin('pluginc', packagesDir); + + final List output = await runCapturingPrint( + runner, ['all-plugins-app', '--exclude=pluginb,pluginc']); + + expect( + output, + containsAllInOrder([ + 'Exluding the following plugins from the combined build:', + ' pluginb', + ' pluginc', + ])); + }); }); } diff --git a/script/tool_runner.sh b/script/tool_runner.sh index 11a54ce435a4..93a7776d0a35 100755 --- a/script/tool_runner.sh +++ b/script/tool_runner.sh @@ -8,7 +8,11 @@ set -e readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" -source "$SCRIPT_DIR/common.sh" +# Runs the plugin tools from the in-tree source. +function plugin_tools() { + (pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null + dart run "$REPO_DIR/script/tool/bin/flutter_plugin_tools.dart" "$@" +} ACTIONS=("$@") From 3ae3a027e40df088b1a354d9f27b840791473aac Mon Sep 17 00:00:00 2001 From: Balvinder Singh Gambhir Date: Sat, 14 Aug 2021 06:32:03 +0530 Subject: [PATCH 199/364] [video_player] removed video player is not functional on ios simulators warning (#4241) --- packages/video_player/video_player/CHANGELOG.md | 4 ++++ packages/video_player/video_player/README.md | 2 -- packages/video_player/video_player/pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index bfed1615f8a6..8898ba665cf9 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.13 + +* Removed obsolete warning about not working in iOS simulators from README. + ## 2.1.12 * Update the video url in the readme code sample diff --git a/packages/video_player/video_player/README.md b/packages/video_player/video_player/README.md index a1d3d935e71c..4d2bf80a2628 100644 --- a/packages/video_player/video_player/README.md +++ b/packages/video_player/video_player/README.md @@ -12,8 +12,6 @@ First, add `video_player` as a [dependency in your pubspec.yaml file](https://fl ### iOS -Warning: The video player is not functional on iOS simulators. An iOS device must be used during development/testing. - Add the following entry to your _Info.plist_ file, located in `/ios/Runner/Info.plist`: ```xml diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 960f0c6ce63a..c4c616cc751f 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.12 +version: 2.1.13 environment: sdk: ">=2.12.0 <3.0.0" From 99c5f6139a196171116de058a68166dc9d0325dc Mon Sep 17 00:00:00 2001 From: nt4f04uNd Date: Sun, 15 Aug 2021 20:32:03 +0300 Subject: [PATCH 200/364] Move test packages from `dependencies` to `dev_dependencies` (#4231) --- .../in_app_purchase/in_app_purchase_android/CHANGELOG.md | 4 ++++ .../in_app_purchase/in_app_purchase_android/pubspec.yaml | 4 ++-- packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md | 4 ++++ packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml | 4 ++-- packages/video_player/video_player/CHANGELOG.md | 4 ++++ packages/video_player/video_player/pubspec.yaml | 6 +++--- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 32f9aa60e4ca..d67d1efd61b5 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.4+4 + +* Removed dependency on the `test` package. + # 0.1.4+3 - Updated installation instructions in README. diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index f8e63821657a..3969e34c052b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+3 +version: 0.1.4+4 environment: sdk: ">=2.12.0 <3.0.0" @@ -22,10 +22,10 @@ dependencies: in_app_purchase_platform_interface: ^1.1.0 json_annotation: ^4.0.1 meta: ^1.3.0 - test: ^1.16.0 dev_dependencies: build_runner: ^1.11.1 flutter_test: sdk: flutter json_serializable: ^4.1.1 + test: ^1.16.0 diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 305d5a13647c..c76409521e2f 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.3+2 + +* Removed dependency on the `test` package. + # 0.1.3+1 - Updated installation instructions in README. diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 5f3b08520eb6..8fc42371f405 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3+1 +version: 0.1.3+2 environment: sdk: ">=2.12.0 <3.0.0" @@ -21,10 +21,10 @@ dependencies: in_app_purchase_platform_interface: ^1.1.0 json_annotation: ^4.0.1 meta: ^1.3.0 - test: ^1.16.0 dev_dependencies: build_runner: ^1.11.1 flutter_test: sdk: flutter json_serializable: ^4.1.1 + test: ^1.16.0 diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 8898ba665cf9..f2029622f0ee 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.14 + +* Removed dependency on the `flutter_test` package. + ## 2.1.13 * Removed obsolete warning about not working in iOS simulators from README. diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index c4c616cc751f..0d0cdb1cb436 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.13 +version: 2.1.14 environment: sdk: ">=2.12.0 <3.0.0" @@ -23,8 +23,6 @@ flutter: dependencies: flutter: sdk: flutter - flutter_test: - sdk: flutter meta: ^1.3.0 video_player_platform_interface: ^4.1.0 # The design on https://flutter.dev/go/federated-plugins was to leave @@ -36,5 +34,7 @@ dependencies: video_player_web: ^2.0.0 dev_dependencies: + flutter_test: + sdk: flutter pedantic: ^1.10.0 pigeon: ^0.1.21 From 954041d5bc76a9747899d0c7b3c66fc941e27c3f Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Mon, 16 Aug 2021 16:27:05 -0700 Subject: [PATCH 201/364] Add unit tests to `quick_actions` plugin (#4245) --- .../quick_actions/android/build.gradle | 9 ++++ .../quickactions/QuickActionsTest.java | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java diff --git a/packages/quick_actions/quick_actions/android/build.gradle b/packages/quick_actions/quick_actions/android/build.gradle index 038f9e99048a..0bce642f3e60 100644 --- a/packages/quick_actions/quick_actions/android/build.gradle +++ b/packages/quick_actions/quick_actions/android/build.gradle @@ -32,6 +32,15 @@ android { disable 'InvalidPackage' } + dependencies { + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-core:3.2.4' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } testOptions { unitTests.includeAndroidResources = true diff --git a/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java b/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java new file mode 100644 index 000000000000..d437444a53be --- /dev/null +++ b/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java @@ -0,0 +1,54 @@ +// Copyright 2013 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. + +package io.flutter.plugins.quickactions; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.StandardMethodCodec; +import java.nio.ByteBuffer; +import org.junit.Test; + +public class QuickActionsTest { + private static class TestBinaryMessenger implements BinaryMessenger { + public MethodCall lastMethodCall; + + @Override + public void send(@NonNull String channel, @Nullable ByteBuffer message) { + send(channel, message, null); + } + + @Override + public void send( + @NonNull String channel, + @Nullable ByteBuffer message, + @Nullable final BinaryReply callback) { + if (channel.equals("plugins.flutter.io/quick_actions")) { + lastMethodCall = + StandardMethodCodec.INSTANCE.decodeMethodCall((ByteBuffer) message.position(0)); + } + } + + @Override + public void setMessageHandler(@NonNull String channel, @Nullable BinaryMessageHandler handler) { + // Do nothing. + } + } + + @Test + public void canAttachToEngine() { + final TestBinaryMessenger testBinaryMessenger = new TestBinaryMessenger(); + final FlutterPluginBinding mockPluginBinding = mock(FlutterPluginBinding.class); + when(mockPluginBinding.getBinaryMessenger()).thenReturn(testBinaryMessenger); + + final QuickActionsPlugin plugin = new QuickActionsPlugin(); + plugin.onAttachedToEngine(mockPluginBinding); + } +} From c52ae9fdf1751cb86ef301b48fab69d4fd6cf832 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 17 Aug 2021 08:36:40 -0700 Subject: [PATCH 202/364] [flutter_plugin_tool] Don't allow NEXT on version bumps (#4246) The special "NEXT" entry in a CHANGELOG should never be present in a commit that bumped the version. This validates that this is true even if the CHANGELOG would be correct for a non-version-change state, to catch someone incorrectly resolving a merge conflict by leaving both parts of the conflict, rather than folding the 'NEXT' entry's list into the new version's notes. Fixes https://github.com/flutter/flutter/issues/85584 --- script/tool/CHANGELOG.md | 2 + .../tool/lib/src/version_check_command.dart | 84 ++++++++++++++----- .../tool/test/version_check_command_test.dart | 54 +++++++++++- 3 files changed, 115 insertions(+), 25 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 7f326ff3c8f7..584ea571f0e1 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -23,6 +23,8 @@ the new `native-test` command. - Commands that print a run summary at the end now track and log exclusions similarly to skips for easier auditing. +- `version-check` now validates that `NEXT` is not present when changing + the version. ## 0.4.1 diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index c08600c3f669..67c563782888 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -32,6 +32,21 @@ enum NextVersionType { RELEASE, } +/// The state of a package's version relative to the comparison base. +enum _CurrentVersionState { + /// The version is unchanged. + unchanged, + + /// The version has changed, and the transition is valid. + validChange, + + /// The version has changed, and the transition is invalid. + invalidChange, + + /// There was an error determining the version state. + unknown, +} + /// Returns the set of allowed next versions, with their change type, for /// [version]. /// @@ -140,11 +155,28 @@ class VersionCheckCommand extends PackageLoopingCommand { final List errors = []; - if (!await _hasValidVersionChange(package, pubspec: pubspec)) { - errors.add('Disallowed version change.'); + bool versionChanged; + final _CurrentVersionState versionState = + await _getVersionState(package, pubspec: pubspec); + switch (versionState) { + case _CurrentVersionState.unchanged: + versionChanged = false; + break; + case _CurrentVersionState.validChange: + versionChanged = true; + break; + case _CurrentVersionState.invalidChange: + versionChanged = true; + errors.add('Disallowed version change.'); + break; + case _CurrentVersionState.unknown: + versionChanged = false; + errors.add('Unable to determine previous version.'); + break; } - if (!(await _hasConsistentVersion(package, pubspec: pubspec))) { + if (!(await _validateChangelogVersion(package, + pubspec: pubspec, pubspecVersionChanged: versionChanged))) { errors.add('pubspec.yaml and CHANGELOG.md have different versions'); } @@ -195,10 +227,9 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} return await gitVersionFinder.getPackageVersion(gitPath); } - /// Returns true if the version of [package] is either unchanged relative to - /// the comparison base (git or pub, depending on flags), or is a valid - /// version transition. - Future _hasValidVersionChange( + /// Returns the state of the verison of [package] relative to the comparison + /// base (git or pub, depending on flags). + Future<_CurrentVersionState> _getVersionState( Directory package, { required Pubspec pubspec, }) async { @@ -208,7 +239,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} if (getBoolArg(_againstPubFlag)) { previousVersion = await _fetchPreviousVersionFromPub(pubspec.name); if (previousVersion == null) { - return false; + return _CurrentVersionState.unknown; } if (previousVersion != Version.none) { print( @@ -225,12 +256,12 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} '${getBoolArg(_againstPubFlag) ? 'on pub server' : 'at git base'}.'); logWarning( '${indentation}If this plugin is not new, something has gone wrong.'); - return true; + return _CurrentVersionState.validChange; // Assume new, thus valid. } if (previousVersion == currentVersion) { print('${indentation}No version change.'); - return true; + return _CurrentVersionState.unchanged; } // Check for reverts when doing local validation. @@ -241,9 +272,9 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} // to be a revert rather than a typo by checking that the transition // from the lower version to the new version would have been valid. if (possibleVersionsFromNewVersion.containsKey(previousVersion)) { - print('${indentation}New version is lower than previous version. ' + logWarning('${indentation}New version is lower than previous version. ' 'This is assumed to be a revert.'); - return true; + return _CurrentVersionState.validChange; } } @@ -257,7 +288,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} printError('${indentation}Incorrectly updated version.\n' '${indentation}HEAD: $currentVersion, $source: $previousVersion.\n' '${indentation}Allowed versions: $allowedNextVersions'); - return false; + return _CurrentVersionState.invalidChange; } final bool isPlatformInterface = @@ -268,16 +299,20 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} allowedNextVersions[currentVersion] == NextVersionType.BREAKING_MAJOR) { printError('${indentation}Breaking change detected.\n' '${indentation}Breaking changes to platform interfaces are strongly discouraged.\n'); - return false; + return _CurrentVersionState.invalidChange; } - return true; + return _CurrentVersionState.validChange; } - /// Returns whether or not the pubspec version and CHANGELOG version for - /// [plugin] match. - Future _hasConsistentVersion( + /// Checks whether or not [package]'s CHANGELOG's versioning is correct, + /// both that it matches [pubspec] and that NEXT is used correctly, printing + /// the results of its checks. + /// + /// Returns false if the CHANGELOG fails validation. + Future _validateChangelogVersion( Directory package, { required Pubspec pubspec, + required bool pubspecVersionChanged, }) async { // This method isn't called unless `version` is non-null. final Version fromPubspec = pubspec.version!; @@ -296,10 +331,19 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} // Remove all leading mark down syntax from the version line. String? versionString = firstLineWithText?.split(' ').last; + final String badNextErrorMessage = '${indentation}When bumping the version ' + 'for release, the NEXT section should be incorporated into the new ' + 'version\'s release notes.'; + // Skip validation for the special NEXT version that's used to accumulate // changes that don't warrant publishing on their own. final bool hasNextSection = versionString == 'NEXT'; if (hasNextSection) { + // NEXT should not be present in a commit that changes the version. + if (pubspecVersionChanged) { + printError(badNextErrorMessage); + return false; + } print( '${indentation}Found NEXT; validating next version in the CHANGELOG.'); // Ensure that the version in pubspec hasn't changed without updating @@ -334,9 +378,7 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog. if (!hasNextSection) { final RegExp nextRegex = RegExp(r'^#+\s*NEXT\s*$'); if (lines.any((String line) => nextRegex.hasMatch(line))) { - printError('${indentation}When bumping the version for release, the ' - 'NEXT section should be incorporated into the new version\'s ' - 'release notes.'); + printError(badNextErrorMessage); return false; } } diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 587de1a58cd9..7765073feb08 100644 --- a/script/tool/test/version_check_command_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -373,6 +373,10 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + final List output = await runCapturingPrint( runner, ['version-check', '--base-sha=master']); await expectLater( @@ -384,8 +388,7 @@ void main() { ); }); - test('Fail if NEXT is left in the CHANGELOG when adding a version bump', - () async { + test('Fail if NEXT appears after a version', () async { const String version = '1.0.1'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, version: version); @@ -419,6 +422,45 @@ void main() { ); }); + test('Fail if NEXT is left in the CHANGELOG when adding a version bump', + () async { + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); + + const String changelog = ''' +## NEXT +* Some changes that should have been folded in 1.0.1. +## $version +* Some changes. +## 1.0.0 +* Some other changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + output, + containsAllInOrder([ + contains('When bumping the version for release, the NEXT section ' + 'should be incorporated into the new version\'s release notes.') + ]), + ); + }); + test('Fail if the version changes without replacing NEXT', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, version: '1.0.1'); @@ -430,6 +472,10 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + bool hasError = false; final List output = await runCapturingPrint(runner, [ 'version-check', @@ -444,8 +490,8 @@ void main() { expect( output, containsAllInOrder([ - contains('Found NEXT; validating next version in the CHANGELOG.'), - contains('Versions in CHANGELOG.md and pubspec.yaml do not match.'), + contains('When bumping the version for release, the NEXT section ' + 'should be incorporated into the new version\'s release notes.') ]), ); }); From d58036f45d825ffbf311ab48a53d993d40468d5a Mon Sep 17 00:00:00 2001 From: Monika Manuela Hengki Date: Wed, 18 Aug 2021 00:20:08 +0800 Subject: [PATCH 203/364] [quick_actions] Android support only calling initialize once (#4204) Fixes flutter/flutter#87259 --- .../quick_actions/quick_actions/CHANGELOG.md | 4 + .../quickactions/MethodCallHandlerImpl.java | 3 +- .../quickactions/QuickActionsPlugin.java | 21 +++- .../quickactions/QuickActionsTest.java | 111 ++++++++++++++++++ .../quick_actions/lib/quick_actions.dart | 2 +- .../quick_actions/quick_actions/pubspec.yaml | 2 +- .../quick_actions_platform.dart | 2 +- 7 files changed, 139 insertions(+), 6 deletions(-) diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index 4f8943845cf7..5d040f4fd74e 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0+5 + +* Support only calling initialize once. + ## 0.6.0+4 * Remove references to the Android V1 embedding. diff --git a/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/MethodCallHandlerImpl.java b/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/MethodCallHandlerImpl.java index 465283053442..2d89352f3e09 100644 --- a/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/MethodCallHandlerImpl.java +++ b/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/MethodCallHandlerImpl.java @@ -20,9 +20,8 @@ import java.util.Map; class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler { - + protected static final String EXTRA_ACTION = "some unique action key"; private static final String CHANNEL_ID = "plugins.flutter.io/quick_actions"; - private static final String EXTRA_ACTION = "some unique action key"; private final Context context; private Activity activity; diff --git a/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/QuickActionsPlugin.java b/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/QuickActionsPlugin.java index ab3431325503..b2f80ad0a271 100644 --- a/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/QuickActionsPlugin.java +++ b/packages/quick_actions/quick_actions/android/src/main/java/io/flutter/plugins/quickactions/QuickActionsPlugin.java @@ -6,14 +6,17 @@ import android.app.Activity; import android.content.Context; +import android.content.Intent; +import android.os.Build; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry.NewIntentListener; /** QuickActionsPlugin */ -public class QuickActionsPlugin implements FlutterPlugin, ActivityAware { +public class QuickActionsPlugin implements FlutterPlugin, ActivityAware, NewIntentListener { private static final String CHANNEL_ID = "plugins.flutter.io/quick_actions"; private MethodChannel channel; @@ -43,6 +46,8 @@ public void onDetachedFromEngine(FlutterPluginBinding binding) { @Override public void onAttachedToActivity(ActivityPluginBinding binding) { handler.setActivity(binding.getActivity()); + binding.addOnNewIntentListener(this); + onNewIntent(binding.getActivity().getIntent()); } @Override @@ -52,6 +57,7 @@ public void onDetachedFromActivity() { @Override public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { + binding.removeOnNewIntentListener(this); onAttachedToActivity(binding); } @@ -60,6 +66,19 @@ public void onDetachedFromActivityForConfigChanges() { onDetachedFromActivity(); } + @Override + public boolean onNewIntent(Intent intent) { + // Do nothing for anything lower than API 25 as the functionality isn't supported. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return false; + } + // Notify the Dart side if the launch intent has the intent extra relevant to quick actions. + if (intent.hasExtra(MethodCallHandlerImpl.EXTRA_ACTION) && channel != null) { + channel.invokeMethod("launch", intent.getStringExtra(MethodCallHandlerImpl.EXTRA_ACTION)); + } + return false; + } + private void setupChannel(BinaryMessenger messenger, Context context, Activity activity) { channel = new MethodChannel(messenger, CHANNEL_ID); handler = new MethodCallHandlerImpl(context, activity); diff --git a/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java b/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java index d437444a53be..208a119efafe 100644 --- a/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java +++ b/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java @@ -4,17 +4,30 @@ package io.flutter.plugins.quickactions; +import static io.flutter.plugins.quickactions.MethodCallHandlerImpl.EXTRA_ACTION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import android.app.Activity; +import android.content.Intent; +import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.StandardMethodCodec; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.nio.ByteBuffer; +import org.junit.After; import org.junit.Test; +import org.mockito.internal.util.reflection.FieldSetter; public class QuickActionsTest { private static class TestBinaryMessenger implements BinaryMessenger { @@ -42,6 +55,10 @@ public void setMessageHandler(@NonNull String channel, @Nullable BinaryMessageHa } } + static final int SUPPORTED_BUILD = 25; + static final int UNSUPPORTED_BUILD = 24; + static final String SHORTCUT_TYPE = "action_one"; + @Test public void canAttachToEngine() { final TestBinaryMessenger testBinaryMessenger = new TestBinaryMessenger(); @@ -51,4 +68,98 @@ public void canAttachToEngine() { final QuickActionsPlugin plugin = new QuickActionsPlugin(); plugin.onAttachedToEngine(mockPluginBinding); } + + @Test + public void onAttachedToActivity_buildVersionSupported_invokesLaunchMethod() + throws NoSuchFieldException, IllegalAccessException { + // Arrange + final TestBinaryMessenger testBinaryMessenger = new TestBinaryMessenger(); + final QuickActionsPlugin plugin = new QuickActionsPlugin(); + setUpMessengerAndFlutterPluginBinding(testBinaryMessenger, plugin); + setBuildVersion(SUPPORTED_BUILD); + FieldSetter.setField( + plugin, + QuickActionsPlugin.class.getDeclaredField("handler"), + mock(MethodCallHandlerImpl.class)); + final Intent mockIntent = createMockIntentWithQuickActionExtra(); + final Activity mockMainActivity = mock(Activity.class); + when(mockMainActivity.getIntent()).thenReturn(mockIntent); + final ActivityPluginBinding mockActivityPluginBinding = mock(ActivityPluginBinding.class); + when(mockActivityPluginBinding.getActivity()).thenReturn(mockMainActivity); + + // Act + plugin.onAttachedToActivity(mockActivityPluginBinding); + + // Assert + assertNotNull(testBinaryMessenger.lastMethodCall); + assertEquals(testBinaryMessenger.lastMethodCall.method, "launch"); + assertEquals(testBinaryMessenger.lastMethodCall.arguments, SHORTCUT_TYPE); + } + + @Test + public void onNewIntent_buildVersionUnsupported_doesNotInvokeMethod() + throws NoSuchFieldException, IllegalAccessException { + // Arrange + final TestBinaryMessenger testBinaryMessenger = new TestBinaryMessenger(); + final QuickActionsPlugin plugin = new QuickActionsPlugin(); + setUpMessengerAndFlutterPluginBinding(testBinaryMessenger, plugin); + setBuildVersion(UNSUPPORTED_BUILD); + final Intent mockIntent = createMockIntentWithQuickActionExtra(); + + // Act + final boolean onNewIntentReturn = plugin.onNewIntent(mockIntent); + + // Assert + assertNull(testBinaryMessenger.lastMethodCall); + assertFalse(onNewIntentReturn); + } + + @Test + public void onNewIntent_buildVersionSupported_invokesLaunchMethod() + throws NoSuchFieldException, IllegalAccessException { + // Arrange + final TestBinaryMessenger testBinaryMessenger = new TestBinaryMessenger(); + final QuickActionsPlugin plugin = new QuickActionsPlugin(); + setUpMessengerAndFlutterPluginBinding(testBinaryMessenger, plugin); + setBuildVersion(SUPPORTED_BUILD); + final Intent mockIntent = createMockIntentWithQuickActionExtra(); + + // Act + final boolean onNewIntentReturn = plugin.onNewIntent(mockIntent); + + // Assert + assertNotNull(testBinaryMessenger.lastMethodCall); + assertEquals(testBinaryMessenger.lastMethodCall.method, "launch"); + assertEquals(testBinaryMessenger.lastMethodCall.arguments, SHORTCUT_TYPE); + assertFalse(onNewIntentReturn); + } + + private void setUpMessengerAndFlutterPluginBinding( + TestBinaryMessenger testBinaryMessenger, QuickActionsPlugin plugin) { + final FlutterPluginBinding mockPluginBinding = mock(FlutterPluginBinding.class); + when(mockPluginBinding.getBinaryMessenger()).thenReturn(testBinaryMessenger); + plugin.onAttachedToEngine(mockPluginBinding); + } + + private Intent createMockIntentWithQuickActionExtra() { + final Intent mockIntent = mock(Intent.class); + when(mockIntent.hasExtra(EXTRA_ACTION)).thenReturn(true); + when(mockIntent.getStringExtra(EXTRA_ACTION)).thenReturn(QuickActionsTest.SHORTCUT_TYPE); + return mockIntent; + } + + private void setBuildVersion(int buildVersion) + throws NoSuchFieldException, IllegalAccessException { + Field buildSdkField = Build.VERSION.class.getField("SDK_INT"); + buildSdkField.setAccessible(true); + final Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(buildSdkField, buildSdkField.getModifiers() & ~Modifier.FINAL); + buildSdkField.set(null, buildVersion); + } + + @After + public void tearDown() throws NoSuchFieldException, IllegalAccessException { + setBuildVersion(0); + } } diff --git a/packages/quick_actions/quick_actions/lib/quick_actions.dart b/packages/quick_actions/quick_actions/lib/quick_actions.dart index 6907f25729ab..7d3d4ad1ef3b 100644 --- a/packages/quick_actions/quick_actions/lib/quick_actions.dart +++ b/packages/quick_actions/quick_actions/lib/quick_actions.dart @@ -16,7 +16,7 @@ class QuickActions { /// Initializes this plugin. /// - /// Call this once before any further interaction with the the plugin. + /// Call this once before any further interaction with the plugin. Future initialize(QuickActionHandler handler) async => QuickActionsPlatform.instance.initialize(handler); diff --git a/packages/quick_actions/quick_actions/pubspec.yaml b/packages/quick_actions/quick_actions/pubspec.yaml index 657c2f001a83..e52ab515432f 100644 --- a/packages/quick_actions/quick_actions/pubspec.yaml +++ b/packages/quick_actions/quick_actions/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for creating shortcuts on home screen, also known as Quick Actions on iOS and App Shortcuts on Android. repository: https://github.com/flutter/plugins/tree/master/packages/quick_actions issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+quick_actions%22 -version: 0.6.0+4 +version: 0.6.0+5 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/quick_actions/quick_actions_platform_interface/lib/platform_interface/quick_actions_platform.dart b/packages/quick_actions/quick_actions_platform_interface/lib/platform_interface/quick_actions_platform.dart index b15fb8b43233..2e06935ccb09 100644 --- a/packages/quick_actions/quick_actions_platform_interface/lib/platform_interface/quick_actions_platform.dart +++ b/packages/quick_actions/quick_actions_platform_interface/lib/platform_interface/quick_actions_platform.dart @@ -38,7 +38,7 @@ abstract class QuickActionsPlatform extends PlatformInterface { /// Initializes this plugin. /// - /// Call this once before any further interaction with the the plugin. + /// Call this once before any further interaction with the plugin. Future initialize(QuickActionHandler handler) async { throw UnimplementedError("initialize() has not been implemented."); } From 04ea39acd6d8c2ec0ed9bb022ae225470285060a Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 17 Aug 2021 09:43:31 -0700 Subject: [PATCH 204/364] [flutter_plugin_tools] Add Android native UI test support (#4188) Adds integration test support for Android to `native-test`. Also fixes an issue where the existing unit test support was not honoring `--no-unit`. Fixes https://github.com/flutter/flutter/issues/86490 --- .../java/io/plugins/DartIntegrationTest.java | 14 ++ .../androidalarmmanager/MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../plugins/battery/FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../cameraexample/FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../googlemapsexample/MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterFragmentActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../packageinfoexample/MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../pathprovider}/MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../sensorsexample/FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../shareexample/FlutterActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../urllauncherexample/MainActivityTest.java | 2 + .../flutter/plugins/DartIntegrationTest.java | 14 ++ .../MainActivityTest.java | 2 + script/tool/CHANGELOG.md | 4 + script/tool/lib/src/native_test_command.dart | 124 +++++++--- script/tool/pubspec.yaml | 2 +- .../tool/test/native_test_command_test.dart | 227 +++++++++++++++++- 42 files changed, 623 insertions(+), 38 deletions(-) create mode 100644 packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/DartIntegrationTest.java create mode 100644 packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java rename packages/path_provider/path_provider/example/android/app/src/androidTest/java/{ => io/flutter/plugins/pathprovider}/MainActivityTest.java (89%) create mode 100644 packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java diff --git a/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/DartIntegrationTest.java b/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/MainActivityTest.java b/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/MainActivityTest.java index 0272c14a8328..a5bb72415f14 100644 --- a/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/MainActivityTest.java +++ b/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/MainActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule diff --git a/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java b/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/androidintentexample/MainActivityTest.java b/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/androidintentexample/MainActivityTest.java index d9ba10729001..358fc78bfcfd 100644 --- a/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/androidintentexample/MainActivityTest.java +++ b/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/androidintentexample/MainActivityTest.java @@ -6,9 +6,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule public ActivityTestRule rule = new ActivityTestRule<>(MainActivity.class); diff --git a/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/FlutterActivityTest.java b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/FlutterActivityTest.java index 267271f70f42..5068d043bdfc 100644 --- a/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/FlutterActivityTest.java +++ b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/battery/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java b/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/FlutterActivityTest.java b/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/FlutterActivityTest.java index 32acc1ba9c15..39cae489d9fa 100644 --- a/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/FlutterActivityTest.java +++ b/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/connectivityexample/FlutterActivityTest.java b/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/connectivityexample/FlutterActivityTest.java index 330f0050a1d8..b4a67622f8dc 100644 --- a/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/connectivityexample/FlutterActivityTest.java +++ b/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/connectivityexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/MainActivityTest.java b/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/MainActivityTest.java index 66a606ca00a9..25999995691d 100644 --- a/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/MainActivityTest.java +++ b/packages/flutter_plugin_android_lifecycle/example/android/app/src/androidTest/java/io/flutter/plugins/flutter_plugin_android_lifecycle/MainActivityTest.java @@ -6,9 +6,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule public ActivityTestRule rule = new ActivityTestRule<>(MainActivity.class); diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java index fccd4c95c3ac..244a22b6c6c8 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java index 36787ffd9910..edc01de491af 100644 --- a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java index 1ca37ce5feb7..91e068fa8043 100644 --- a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java +++ b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java index a60599573d57..03e4066de85e 100644 --- a/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java +++ b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java index a60599573d57..03e4066de85e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/inapppurchaseexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/FlutterFragmentActivityTest.java b/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/FlutterFragmentActivityTest.java index e5ece3edd50d..68c22371d7dd 100644 --- a/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/FlutterFragmentActivityTest.java +++ b/packages/local_auth/example/android/app/src/androidTest/java/io/flutter/plugins/localauth/FlutterFragmentActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterFragmentActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterFragmentActivityTest { @Rule diff --git a/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/MainActivityTest.java b/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/MainActivityTest.java index cf7252ce19de..fb63f6f8c88b 100644 --- a/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/MainActivityTest.java +++ b/packages/package_info/example/android/app/src/androidTest/java/io/flutter/plugins/packageinfoexample/MainActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule diff --git a/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/path_provider/path_provider/example/android/app/src/androidTest/java/MainActivityTest.java b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/pathprovider/MainActivityTest.java similarity index 89% rename from packages/path_provider/path_provider/example/android/app/src/androidTest/java/MainActivityTest.java rename to packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/pathprovider/MainActivityTest.java index 0380a4397ae6..d56458bd753c 100644 --- a/packages/path_provider/path_provider/example/android/app/src/androidTest/java/MainActivityTest.java +++ b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/pathprovider/MainActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule diff --git a/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java index 0b60dfa53e1f..e96548da291a 100644 --- a/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java +++ b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/sensorsexample/FlutterActivityTest.java b/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/sensorsexample/FlutterActivityTest.java index c1584aab107c..52a6b8bebaf3 100644 --- a/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/sensorsexample/FlutterActivityTest.java +++ b/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/sensorsexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/share/example/android/app/src/main/java/io/flutter/plugins/shareexample/FlutterActivityTest.java b/packages/share/example/android/app/src/main/java/io/flutter/plugins/shareexample/FlutterActivityTest.java index 070749dcff20..aba658887d88 100644 --- a/packages/share/example/android/app/src/main/java/io/flutter/plugins/shareexample/FlutterActivityTest.java +++ b/packages/share/example/android/app/src/main/java/io/flutter/plugins/shareexample/FlutterActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java b/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/MainActivityTest.java b/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/MainActivityTest.java index 9e343b82a193..67f15efb10aa 100644 --- a/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/MainActivityTest.java +++ b/packages/url_launcher/url_launcher/example/android/app/src/androidTestDebug/java/io/flutter/plugins/urllauncherexample/MainActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class FlutterActivityTest { @Rule diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java index b18308ab2feb..a32aaebb0ecd 100644 --- a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java @@ -7,9 +7,11 @@ import androidx.test.rule.ActivityTestRule; import dev.flutter.plugins.integration_test.FlutterTestRunner; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; import org.junit.Rule; import org.junit.runner.RunWith; +@DartIntegrationTest @RunWith(FlutterTestRunner.class) public class MainActivityTest { @Rule diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 584ea571f0e1..267019fe7359 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,5 +1,9 @@ ## NEXT +- Added Android native integration test support to `native-test`. + +## 0.5.0 + - `--exclude` and `--custom-analysis` now accept paths to YAML files that contain lists of packages to exclude, in addition to just package names, so that exclude lists can be maintained separately from scripts and CI diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 36b12741f2ce..9fc6a2912ccc 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -96,11 +96,6 @@ this command. throw ToolExit(exitInvalidArguments); } - if (getBoolArg(kPlatformAndroid) && getBoolArg(_integrationTestFlag)) { - logWarning('This command currently only supports unit tests for Android. ' - 'See https://github.com/flutter/flutter/issues/86490.'); - } - // iOS-specific run-level state. if (_requestedPlatforms.contains('ios')) { String destination = getStringArg(_iosDestinationFlag); @@ -178,12 +173,8 @@ this command. } Future<_PlatformResult> _testAndroid(Directory plugin, _TestMode mode) async { - final List examplesWithTests = []; - for (final Directory example in getExamplesForPlugin(plugin)) { - if (!isFlutterPackage(example)) { - continue; - } - if (example + bool exampleHasUnitTests(Directory example) { + return example .childDirectory('android') .childDirectory('app') .childDirectory('src') @@ -193,20 +184,62 @@ this command. .childDirectory('android') .childDirectory('src') .childDirectory('test') - .existsSync()) { - examplesWithTests.add(example); - } else { - _printNoExampleTestsMessage(example, 'Android'); - } + .existsSync(); } - if (examplesWithTests.isEmpty) { - return _PlatformResult(RunState.skipped); + bool exampleHasNativeIntegrationTests(Directory example) { + final Directory integrationTestDirectory = example + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('androidTest'); + // There are two types of integration tests that can be in the androidTest + // directory: + // - FlutterTestRunner.class tests, which bridge to Dart integration tests + // - Purely native tests + // Only the latter is supported by this command; the former will hang if + // run here because they will wait for a Dart call that will never come. + // + // This repository uses a convention of putting the former in a + // *ActivityTest.java file, so ignore that file when checking for tests. + // Also ignore DartIntegrationTest.java, which defines the annotation used + // below for filtering the former out when running tests. + // + // If those are the only files, then there are no tests to run here. + return integrationTestDirectory.existsSync() && + integrationTestDirectory + .listSync(recursive: true) + .whereType() + .any((File file) { + final String basename = file.basename; + return !basename.endsWith('ActivityTest.java') && + basename != 'DartIntegrationTest.java'; + }); } + final Iterable examples = getExamplesForPlugin(plugin); + + bool ranTests = false; bool failed = false; bool hasMissingBuild = false; - for (final Directory example in examplesWithTests) { + for (final Directory example in examples) { + final bool hasUnitTests = exampleHasUnitTests(example); + final bool hasIntegrationTests = + exampleHasNativeIntegrationTests(example); + + if (mode.unit && !hasUnitTests) { + _printNoExampleTestsMessage(example, 'Android unit'); + } + if (mode.integration && !hasIntegrationTests) { + _printNoExampleTestsMessage(example, 'Android integration'); + } + + final bool runUnitTests = mode.unit && hasUnitTests; + final bool runIntegrationTests = mode.integration && hasIntegrationTests; + if (!runUnitTests && !runIntegrationTests) { + continue; + } + final String exampleName = getPackageDescription(example); _printRunningExampleTestsMessage(example, 'Android'); @@ -221,17 +254,52 @@ this command. continue; } - final int exitCode = await processRunner.runAndStream( - gradleFile.path, ['testDebugUnitTest'], - workingDir: androidDirectory); - if (exitCode != 0) { - printError('$exampleName tests failed.'); - failed = true; + if (runUnitTests) { + print('Running unit tests...'); + final int exitCode = await processRunner.runAndStream( + gradleFile.path, ['testDebugUnitTest'], + workingDir: androidDirectory); + if (exitCode != 0) { + printError('$exampleName unit tests failed.'); + failed = true; + } + ranTests = true; } + + if (runIntegrationTests) { + // FlutterTestRunner-based tests will hang forever if run in a normal + // app build, since they wait for a Dart call from integration_test that + // will never come. Those tests have an extra annotation to allow + // filtering them out. + const String filter = + 'notAnnotation=io.flutter.plugins.DartIntegrationTest'; + + print('Running integration tests...'); + final int exitCode = await processRunner.runAndStream( + gradleFile.path, + [ + 'app:connectedAndroidTest', + '-Pandroid.testInstrumentationRunnerArguments.$filter', + ], + workingDir: androidDirectory); + if (exitCode != 0) { + printError('$exampleName integration tests failed.'); + failed = true; + } + ranTests = true; + } + } + + if (failed) { + return _PlatformResult(RunState.failed, + error: hasMissingBuild + ? 'Examples must be built before testing.' + : null); + } + if (!ranTests) { + return _PlatformResult(RunState.skipped); } - return _PlatformResult(failed ? RunState.failed : RunState.succeeded, - error: - hasMissingBuild ? 'Examples must be built before testing.' : null); + return _PlatformResult(RunState.succeeded); } Future<_PlatformResult> _testIos(Directory plugin, _TestMode mode) { diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 7b2cdd4f4101..02b3ca624b96 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.4.1 +version: 0.5.0 dependencies: args: ^2.1.0 diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index e656e2f23721..59ca17b25c0b 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -16,6 +16,10 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'util.dart'; +const String _androidIntegrationTestFilter = + '-Pandroid.testInstrumentationRunnerArguments.' + 'notAnnotation=io.flutter.plugins.DartIntegrationTest'; + final Map _kDeviceListMap = { 'runtimes': >[ { @@ -353,7 +357,7 @@ void main() { }); group('Android', () { - test('runs Java tests in Android implementation folder', () async { + test('runs Java unit tests in Android implementation folder', () async { final Directory plugin = createFakePlugin( 'plugin', packagesDir, @@ -383,7 +387,7 @@ void main() { ); }); - test('runs Java tests in example folder', () async { + test('runs Java unit tests in example folder', () async { final Directory plugin = createFakePlugin( 'plugin', packagesDir, @@ -413,6 +417,172 @@ void main() { ); }); + test('runs Java integration tests', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + await runCapturingPrint(runner, ['native-test', '--android']); + + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const [ + 'app:connectedAndroidTest', + _androidIntegrationTestFilter, + ], + androidFolder.path, + ), + ]), + ); + }); + + test( + 'ignores Java integration test files associated with integration_test', + () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java', + 'example/android/app/src/androidTest/java/io/flutter/plugins/plugin/FlutterActivityTest.java', + 'example/android/app/src/androidTest/java/io/flutter/plugins/plugin/MainActivityTest.java', + ], + ); + + await runCapturingPrint(runner, ['native-test', '--android']); + + // Nothing should run since those files are all + // integration_test-specific. + expect( + processRunner.recordedCalls, + orderedEquals([]), + ); + }); + + test('runs all tests when present', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'android/src/test/example_test.java', + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + await runCapturingPrint(runner, ['native-test', '--android']); + + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const ['testDebugUnitTest'], + androidFolder.path, + ), + ProcessCall( + androidFolder.childFile('gradlew').path, + const [ + 'app:connectedAndroidTest', + _androidIntegrationTestFilter, + ], + androidFolder.path, + ), + ]), + ); + }); + + test('honors --no-unit', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'android/src/test/example_test.java', + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + await runCapturingPrint( + runner, ['native-test', '--android', '--no-unit']); + + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const [ + 'app:connectedAndroidTest', + _androidIntegrationTestFilter, + ], + androidFolder.path, + ), + ]), + ); + }); + + test('honors --no-integration', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'android/src/test/example_test.java', + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + await runCapturingPrint( + runner, ['native-test', '--android', '--no-integration']); + + final Directory androidFolder = + plugin.childDirectory('example').childDirectory('android'); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidFolder.childFile('gradlew').path, + const ['testDebugUnitTest'], + androidFolder.path, + ), + ]), + ); + }); + test('fails when the app needs to be built', () async { createFakePlugin( 'plugin', @@ -444,6 +614,46 @@ void main() { ); }); + test('logs missing test types', () async { + // No unit tests. + createFakePlugin( + 'plugin1', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + // No integration tests. + createFakePlugin( + 'plugin2', + packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }, + extraFiles: [ + 'android/src/test/example_test.java', + 'example/android/gradlew', + ], + ); + + final List output = await runCapturingPrint( + runner, ['native-test', '--android']); + + expect( + output, + containsAllInOrder([ + contains('No Android unit tests found for plugin1/example'), + contains('Running integration tests...'), + contains( + 'No Android integration tests found for plugin2/example'), + contains('Running unit tests...'), + ])); + }); + test('fails when a test fails', () async { final Directory pluginDir = createFakePlugin( 'plugin', @@ -478,7 +688,7 @@ void main() { expect( output, containsAllInOrder([ - contains('plugin/example tests failed.'), + contains('plugin/example unit tests failed.'), contains('The following packages had errors:'), contains('plugin') ]), @@ -518,7 +728,8 @@ void main() { expect( output, containsAllInOrder([ - contains('No Android tests found for plugin/example'), + contains('No Android unit tests found for plugin/example'), + contains('No Android integration tests found for plugin/example'), contains('SKIPPING: No tests found.'), ]), ); @@ -810,10 +1021,8 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - androidFolder.childFile('gradlew').path, - const ['testDebugUnitTest'], - androidFolder.path), + ProcessCall(androidFolder.childFile('gradlew').path, + const ['testDebugUnitTest'], androidFolder.path), ProcessCall( 'xcrun', const [ @@ -1003,7 +1212,7 @@ void main() { output, containsAllInOrder([ contains('Running tests for Android...'), - contains('plugin/example tests failed.'), + contains('plugin/example unit tests failed.'), contains('Running tests for iOS...'), contains('Successfully ran iOS xctest for plugin/example'), contains('The following packages had errors:'), From 90fd90ed62571fb765df8c49000b14e3c8c643ec Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 17 Aug 2021 12:09:03 -0700 Subject: [PATCH 205/364] [url_launcher] Add native unit tests for Windows (#4156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a unit test target based on googletest. This is intended to be both a set of unit tests for this plugin, and also a model of changes that can be made to the `flutter create` template for Windows plugins to include better testing out of the box (https://github.com/flutter/flutter/issues/82458). In addition to the test binary being directly runnable, the integration between CMake, VS, and googletest means that these tests are visible—and runnable—in the VS Test Explorer UI after opening the generated .sln file. Changes for testing in general: - Moved the plugin class declaration to a header. - Moved the C registration API implementation to a separate file. - Added (opt-in, so it won't affect plugin client builds) plugin CMake rules to download googletest and build a new executable target that builds all the plugin sources, plus gtest and gmock. - Added a line to the example app CMake rules to enable the unit tests. - Added a unit test file. url_launcher-specific changes: - Wrapped all Win32 calls in a thin class for mockability in unit tests. - Factored some logic into helpers for better maintainability while I was refactoring anyway. Note: This unit test is not yet being run by CI. A tools command to run Windows plugin unit tests will be a separate PR. Part of https://github.com/flutter/flutter/issues/82445 --- .../url_launcher_windows/CHANGELOG.md | 4 + .../example/windows/CMakeLists.txt | 3 + .../example/windows/flutter/CMakeLists.txt | 1 + .../flutter/generated_plugin_registrant.cc | 6 +- .../url_launcher_windows/pubspec.yaml | 2 +- .../windows/CMakeLists.txt | 55 +++++- ...uncher_plugin.h => url_launcher_windows.h} | 2 +- .../windows/system_apis.cpp | 38 ++++ .../windows/system_apis.h | 56 ++++++ .../test/url_launcher_windows_test.cpp | 162 ++++++++++++++++++ .../windows/url_launcher_plugin.cpp | 97 ++++++----- .../windows/url_launcher_plugin.h | 48 ++++++ .../windows/url_launcher_windows.cpp | 15 ++ 13 files changed, 436 insertions(+), 53 deletions(-) rename packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/{url_launcher_plugin.h => url_launcher_windows.h} (92%) create mode 100644 packages/url_launcher/url_launcher_windows/windows/system_apis.cpp create mode 100644 packages/url_launcher/url_launcher_windows/windows/system_apis.h create mode 100644 packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp create mode 100644 packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h create mode 100644 packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp diff --git a/packages/url_launcher/url_launcher_windows/CHANGELOG.md b/packages/url_launcher/url_launcher_windows/CHANGELOG.md index d26fe19c359e..d095a52341b5 100644 --- a/packages/url_launcher/url_launcher_windows/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Added unit tests. + ## 2.0.2 * Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. diff --git a/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt b/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt index abf90408efb4..5b1622bcb333 100644 --- a/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt +++ b/packages/url_launcher/url_launcher_windows/example/windows/CMakeLists.txt @@ -46,6 +46,9 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build add_subdirectory("runner") +# Enable the test target. +set(include_url_launcher_windows_tests TRUE) + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) diff --git a/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt b/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt index c7a8c7607d81..744f08a9389b 100644 --- a/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt +++ b/packages/url_launcher/url_launcher_windows/example/windows/flutter/CMakeLists.txt @@ -91,6 +91,7 @@ add_custom_command( ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" windows-x64 $ + VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" diff --git a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc index d9fdd53925c5..4f7884874da7 100644 --- a/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/url_launcher/url_launcher_windows/example/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,9 @@ #include "generated_plugin_registrant.h" -#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { - UrlLauncherPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/packages/url_launcher/url_launcher_windows/pubspec.yaml b/packages/url_launcher/url_launcher_windows/pubspec.yaml index 6435eda4564a..a92e91ee4568 100644 --- a/packages/url_launcher/url_launcher_windows/pubspec.yaml +++ b/packages/url_launcher/url_launcher_windows/pubspec.yaml @@ -13,7 +13,7 @@ flutter: implements: url_launcher platforms: windows: - pluginClass: UrlLauncherPlugin + pluginClass: UrlLauncherWindows dependencies: flutter: diff --git a/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt b/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt index 57d87e3f6f85..a4185acff6a1 100644 --- a/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt +++ b/packages/url_launcher/url_launcher_windows/windows/CMakeLists.txt @@ -4,12 +4,20 @@ project(${PROJECT_NAME} LANGUAGES CXX) set(PLUGIN_NAME "${PROJECT_NAME}_plugin") -add_library(${PLUGIN_NAME} SHARED +list(APPEND PLUGIN_SOURCES + "system_apis.cpp" + "system_apis.h" "url_launcher_plugin.cpp" + "url_launcher_plugin.h" +) + +add_library(${PLUGIN_NAME} SHARED + "include/url_launcher_windows/url_launcher_windows.h" + "url_launcher_windows.cpp" + ${PLUGIN_SOURCES} ) apply_standard_settings(${PLUGIN_NAME}) -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") @@ -20,3 +28,44 @@ set(file_chooser_bundled_libraries "" PARENT_SCOPE ) + + +# === Tests === + +if (${include_${PROJECT_NAME}_tests}) +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() +# TODO(stuartmorgan): Consider using a single shared, pre-checked-in googletest +# instance rather than downloading for each plugin. This approach makes sense +# for a template, but not for a monorepo with many plugins. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + +FetchContent_MakeAvailable(googletest) + +# The plugin's C API is not very useful for unit testing, so build the sources +# directly into the test binary rather than using the DLL. +add_executable(${TEST_RUNNER} + test/url_launcher_windows_test.cpp + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) +# flutter_wrapper_plugin has link dependencies on the Flutter DLL. +add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${FLUTTER_LIBRARY}" $ +) + +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_plugin.h b/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h similarity index 92% rename from packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_plugin.h rename to packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h index 8af3924ded81..251471c9fe56 100644 --- a/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_plugin.h +++ b/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h @@ -16,7 +16,7 @@ extern "C" { #endif -FLUTTER_PLUGIN_EXPORT void UrlLauncherPluginRegisterWithRegistrar( +FLUTTER_PLUGIN_EXPORT void UrlLauncherWindowsRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar); #if defined(__cplusplus) diff --git a/packages/url_launcher/url_launcher_windows/windows/system_apis.cpp b/packages/url_launcher/url_launcher_windows/windows/system_apis.cpp new file mode 100644 index 000000000000..abd690b6e47f --- /dev/null +++ b/packages/url_launcher/url_launcher_windows/windows/system_apis.cpp @@ -0,0 +1,38 @@ +// Copyright 2013 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. +#include "system_apis.h" + +#include + +namespace url_launcher_plugin { + +SystemApis::SystemApis() {} + +SystemApis::~SystemApis() {} + +SystemApisImpl::SystemApisImpl() {} + +SystemApisImpl::~SystemApisImpl() {} + +LSTATUS SystemApisImpl::RegCloseKey(HKEY key) { return ::RegCloseKey(key); } + +LSTATUS SystemApisImpl::RegOpenKeyExW(HKEY key, LPCWSTR sub_key, DWORD options, + REGSAM desired, PHKEY result) { + return ::RegOpenKeyExW(key, sub_key, options, desired, result); +} + +LSTATUS SystemApisImpl::RegQueryValueExW(HKEY key, LPCWSTR value_name, + LPDWORD type, LPBYTE data, + LPDWORD data_size) { + return ::RegQueryValueExW(key, value_name, nullptr, type, data, data_size); +} + +HINSTANCE SystemApisImpl::ShellExecuteW(HWND hwnd, LPCWSTR operation, + LPCWSTR file, LPCWSTR parameters, + LPCWSTR directory, int show_flags) { + return ::ShellExecuteW(hwnd, operation, file, parameters, directory, + show_flags); +} + +} // namespace url_launcher_plugin diff --git a/packages/url_launcher/url_launcher_windows/windows/system_apis.h b/packages/url_launcher/url_launcher_windows/windows/system_apis.h new file mode 100644 index 000000000000..7b56704d8e04 --- /dev/null +++ b/packages/url_launcher/url_launcher_windows/windows/system_apis.h @@ -0,0 +1,56 @@ +// Copyright 2013 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. +#include + +namespace url_launcher_plugin { + +// An interface wrapping system APIs used by the plugin, for mocking. +class SystemApis { + public: + SystemApis(); + virtual ~SystemApis(); + + // Disallow copy and move. + SystemApis(const SystemApis&) = delete; + SystemApis& operator=(const SystemApis&) = delete; + + // Wrapper for RegCloseKey. + virtual LSTATUS RegCloseKey(HKEY key) = 0; + + // Wrapper for RegQueryValueEx. + virtual LSTATUS RegQueryValueExW(HKEY key, LPCWSTR value_name, LPDWORD type, + LPBYTE data, LPDWORD data_size) = 0; + + // Wrapper for RegOpenKeyEx. + virtual LSTATUS RegOpenKeyExW(HKEY key, LPCWSTR sub_key, DWORD options, + REGSAM desired, PHKEY result) = 0; + + // Wrapper for ShellExecute. + virtual HINSTANCE ShellExecuteW(HWND hwnd, LPCWSTR operation, LPCWSTR file, + LPCWSTR parameters, LPCWSTR directory, + int show_flags) = 0; +}; + +// Implementation of SystemApis using the Win32 APIs. +class SystemApisImpl : public SystemApis { + public: + SystemApisImpl(); + virtual ~SystemApisImpl(); + + // Disallow copy and move. + SystemApisImpl(const SystemApisImpl&) = delete; + SystemApisImpl& operator=(const SystemApisImpl&) = delete; + + // SystemApis Implementation: + virtual LSTATUS RegCloseKey(HKEY key); + virtual LSTATUS RegOpenKeyExW(HKEY key, LPCWSTR sub_key, DWORD options, + REGSAM desired, PHKEY result); + virtual LSTATUS RegQueryValueExW(HKEY key, LPCWSTR value_name, LPDWORD type, + LPBYTE data, LPDWORD data_size); + virtual HINSTANCE ShellExecuteW(HWND hwnd, LPCWSTR operation, LPCWSTR file, + LPCWSTR parameters, LPCWSTR directory, + int show_flags); +}; + +} // namespace url_launcher_plugin diff --git a/packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp b/packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp new file mode 100644 index 000000000000..191d51a0caa8 --- /dev/null +++ b/packages/url_launcher/url_launcher_windows/windows/test/url_launcher_windows_test.cpp @@ -0,0 +1,162 @@ +// Copyright 2013 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. +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "url_launcher_plugin.h" + +namespace url_launcher_plugin { +namespace test { + +namespace { + +using flutter::EncodableMap; +using flutter::EncodableValue; +using ::testing::DoAll; +using ::testing::Pointee; +using ::testing::Return; +using ::testing::SetArgPointee; + +class MockSystemApis : public SystemApis { + public: + MOCK_METHOD(LSTATUS, RegCloseKey, (HKEY key), (override)); + MOCK_METHOD(LSTATUS, RegQueryValueExW, + (HKEY key, LPCWSTR value_name, LPDWORD type, LPBYTE data, + LPDWORD data_size), + (override)); + MOCK_METHOD(LSTATUS, RegOpenKeyExW, + (HKEY key, LPCWSTR sub_key, DWORD options, REGSAM desired, + PHKEY result), + (override)); + MOCK_METHOD(HINSTANCE, ShellExecuteW, + (HWND hwnd, LPCWSTR operation, LPCWSTR file, LPCWSTR parameters, + LPCWSTR directory, int show_flags), + (override)); +}; + +class MockMethodResult : public flutter::MethodResult<> { + public: + MOCK_METHOD(void, SuccessInternal, (const EncodableValue* result), + (override)); + MOCK_METHOD(void, ErrorInternal, + (const std::string& error_code, const std::string& error_message, + const EncodableValue* details), + (override)); + MOCK_METHOD(void, NotImplementedInternal, (), (override)); +}; + +std::unique_ptr CreateArgumentsWithUrl(const std::string& url) { + EncodableMap args = { + {EncodableValue("url"), EncodableValue(url)}, + }; + return std::make_unique(args); +} + +} // namespace + +TEST(UrlLauncherPlugin, CanLaunchSuccessTrue) { + std::unique_ptr system = std::make_unique(); + std::unique_ptr result = + std::make_unique(); + + // Return success values from the registery commands. + HKEY fake_key = reinterpret_cast(1); + EXPECT_CALL(*system, RegOpenKeyExW) + .WillOnce(DoAll(SetArgPointee<4>(fake_key), Return(ERROR_SUCCESS))); + EXPECT_CALL(*system, RegQueryValueExW).WillOnce(Return(ERROR_SUCCESS)); + EXPECT_CALL(*system, RegCloseKey(fake_key)).WillOnce(Return(ERROR_SUCCESS)); + // Expect a success response. + EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(true)))); + + UrlLauncherPlugin plugin(std::move(system)); + plugin.HandleMethodCall( + flutter::MethodCall("canLaunch", + CreateArgumentsWithUrl("https://some.url.com")), + std::move(result)); +} + +TEST(UrlLauncherPlugin, CanLaunchQueryFailure) { + std::unique_ptr system = std::make_unique(); + std::unique_ptr result = + std::make_unique(); + + // Return success values from the registery commands, except for the query, + // to simulate a scheme that is in the registry, but has no URL handler. + HKEY fake_key = reinterpret_cast(1); + EXPECT_CALL(*system, RegOpenKeyExW) + .WillOnce(DoAll(SetArgPointee<4>(fake_key), Return(ERROR_SUCCESS))); + EXPECT_CALL(*system, RegQueryValueExW).WillOnce(Return(ERROR_FILE_NOT_FOUND)); + EXPECT_CALL(*system, RegCloseKey(fake_key)).WillOnce(Return(ERROR_SUCCESS)); + // Expect a success response. + EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(false)))); + + UrlLauncherPlugin plugin(std::move(system)); + plugin.HandleMethodCall( + flutter::MethodCall("canLaunch", + CreateArgumentsWithUrl("https://some.url.com")), + std::move(result)); +} + +TEST(UrlLauncherPlugin, CanLaunchHandlesOpenFailure) { + std::unique_ptr system = std::make_unique(); + std::unique_ptr result = + std::make_unique(); + + // Return failure for opening. + EXPECT_CALL(*system, RegOpenKeyExW).WillOnce(Return(ERROR_BAD_PATHNAME)); + // Expect a success response. + EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(false)))); + + UrlLauncherPlugin plugin(std::move(system)); + plugin.HandleMethodCall( + flutter::MethodCall("canLaunch", + CreateArgumentsWithUrl("https://some.url.com")), + std::move(result)); +} + +TEST(UrlLauncherPlugin, LaunchSuccess) { + std::unique_ptr system = std::make_unique(); + std::unique_ptr result = + std::make_unique(); + + // Return a success value (>32) from launching. + EXPECT_CALL(*system, ShellExecuteW) + .WillOnce(Return(reinterpret_cast(33))); + // Expect a success response. + EXPECT_CALL(*result, SuccessInternal(Pointee(EncodableValue(true)))); + + UrlLauncherPlugin plugin(std::move(system)); + plugin.HandleMethodCall( + flutter::MethodCall("launch", + CreateArgumentsWithUrl("https://some.url.com")), + std::move(result)); +} + +TEST(UrlLauncherPlugin, LaunchReportsFailure) { + std::unique_ptr system = std::make_unique(); + std::unique_ptr result = + std::make_unique(); + + // Return a faile value (<=32) from launching. + EXPECT_CALL(*system, ShellExecuteW) + .WillOnce(Return(reinterpret_cast(32))); + // Expect an error response. + EXPECT_CALL(*result, ErrorInternal); + + UrlLauncherPlugin plugin(std::move(system)); + plugin.HandleMethodCall( + flutter::MethodCall("launch", + CreateArgumentsWithUrl("https://some.url.com")), + std::move(result)); +} + +} // namespace test +} // namespace url_launcher_plugin diff --git a/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp index 51740a3a4b04..748c75ddd243 100644 --- a/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp +++ b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.cpp @@ -1,7 +1,7 @@ // Copyright 2013 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. -#include "include/url_launcher_windows/url_launcher_plugin.h" +#include "url_launcher_plugin.h" #include #include @@ -9,9 +9,12 @@ #include #include +#include #include #include +namespace url_launcher_plugin { + namespace { using flutter::EncodableMap; @@ -54,19 +57,7 @@ std::string GetUrlArgument(const flutter::MethodCall<>& method_call) { return url; } -class UrlLauncherPlugin : public flutter::Plugin { - public: - static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar); - - virtual ~UrlLauncherPlugin(); - - private: - UrlLauncherPlugin(); - - // Called when a method is called on plugin channel; - void HandleMethodCall(const flutter::MethodCall<>& method_call, - std::unique_ptr> result); -}; +} // namespace // static void UrlLauncherPlugin::RegisterWithRegistrar( @@ -75,8 +66,8 @@ void UrlLauncherPlugin::RegisterWithRegistrar( registrar->messenger(), "plugins.flutter.io/url_launcher", &flutter::StandardMethodCodec::GetInstance()); - // Uses new instead of make_unique due to private constructor. - std::unique_ptr plugin(new UrlLauncherPlugin()); + std::unique_ptr plugin = + std::make_unique(); channel->SetMethodCallHandler( [plugin_pointer = plugin.get()](const auto& call, auto result) { @@ -86,7 +77,11 @@ void UrlLauncherPlugin::RegisterWithRegistrar( registrar->AddPlugin(std::move(plugin)); } -UrlLauncherPlugin::UrlLauncherPlugin() = default; +UrlLauncherPlugin::UrlLauncherPlugin() + : system_apis_(std::make_unique()) {} + +UrlLauncherPlugin::UrlLauncherPlugin(std::unique_ptr system_apis) + : system_apis_(std::move(system_apis)) {} UrlLauncherPlugin::~UrlLauncherPlugin() = default; @@ -99,17 +94,10 @@ void UrlLauncherPlugin::HandleMethodCall( result->Error("argument_error", "No URL provided"); return; } - std::wstring url_wide = Utf16FromUtf8(url); - - int status = static_cast(reinterpret_cast( - ::ShellExecute(nullptr, TEXT("open"), url_wide.c_str(), nullptr, - nullptr, SW_SHOWNORMAL))); - if (status <= 32) { - std::ostringstream error_message; - error_message << "Failed to open " << url << ": ShellExecute error code " - << status; - result->Error("open_error", error_message.str()); + std::optional error = LaunchUrl(url); + if (error) { + result->Error("open_error", error.value()); return; } result->Success(EncodableValue(true)); @@ -120,29 +108,48 @@ void UrlLauncherPlugin::HandleMethodCall( return; } - bool can_launch = false; - size_t separator_location = url.find(":"); - if (separator_location != std::string::npos) { - std::wstring scheme = Utf16FromUtf8(url.substr(0, separator_location)); - HKEY key = nullptr; - if (::RegOpenKeyEx(HKEY_CLASSES_ROOT, scheme.c_str(), 0, KEY_QUERY_VALUE, - &key) == ERROR_SUCCESS) { - can_launch = ::RegQueryValueEx(key, L"URL Protocol", nullptr, nullptr, - nullptr, nullptr) == ERROR_SUCCESS; - ::RegCloseKey(key); - } - } + bool can_launch = CanLaunchUrl(url); result->Success(EncodableValue(can_launch)); } else { result->NotImplemented(); } } -} // namespace +bool UrlLauncherPlugin::CanLaunchUrl(const std::string& url) { + size_t separator_location = url.find(":"); + if (separator_location == std::string::npos) { + return false; + } + std::wstring scheme = Utf16FromUtf8(url.substr(0, separator_location)); + + HKEY key = nullptr; + if (system_apis_->RegOpenKeyExW(HKEY_CLASSES_ROOT, scheme.c_str(), 0, + KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) { + return false; + } + bool has_handler = + system_apis_->RegQueryValueExW(key, L"URL Protocol", nullptr, nullptr, + nullptr) == ERROR_SUCCESS; + system_apis_->RegCloseKey(key); + return has_handler; +} -void UrlLauncherPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar) { - UrlLauncherPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarManager::GetInstance() - ->GetRegistrar(registrar)); +std::optional UrlLauncherPlugin::LaunchUrl( + const std::string& url) { + std::wstring url_wide = Utf16FromUtf8(url); + + int status = static_cast(reinterpret_cast( + system_apis_->ShellExecuteW(nullptr, TEXT("open"), url_wide.c_str(), + nullptr, nullptr, SW_SHOWNORMAL))); + + // Per ::ShellExecuteW documentation, anything >32 indicates success. + if (status <= 32) { + std::ostringstream error_message; + error_message << "Failed to open " << url << ": ShellExecute error code " + << status; + return std::optional(error_message.str()); + } + return std::nullopt; } + +} // namespace url_launcher_plugin diff --git a/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h new file mode 100644 index 000000000000..45e70e5fc067 --- /dev/null +++ b/packages/url_launcher/url_launcher_windows/windows/url_launcher_plugin.h @@ -0,0 +1,48 @@ +// Copyright 2013 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. +#include +#include +#include + +#include +#include +#include +#include + +#include "system_apis.h" + +namespace url_launcher_plugin { + +class UrlLauncherPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar); + + UrlLauncherPlugin(); + + // Creates a plugin instance with the given SystemApi instance. + // + // Exists for unit testing with mock implementations. + UrlLauncherPlugin(std::unique_ptr system_apis); + + virtual ~UrlLauncherPlugin(); + + // Disallow copy and move. + UrlLauncherPlugin(const UrlLauncherPlugin&) = delete; + UrlLauncherPlugin& operator=(const UrlLauncherPlugin&) = delete; + + // Called when a method is called on the plugin channel. + void HandleMethodCall(const flutter::MethodCall<>& method_call, + std::unique_ptr> result); + + private: + // Returns whether or not the given URL has a registered handler. + bool CanLaunchUrl(const std::string& url); + + // Attempts to launch the given URL. On failure, returns an error string. + std::optional LaunchUrl(const std::string& url); + + std::unique_ptr system_apis_; +}; + +} // namespace url_launcher_plugin diff --git a/packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp b/packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp new file mode 100644 index 000000000000..05de586d8fe0 --- /dev/null +++ b/packages/url_launcher/url_launcher_windows/windows/url_launcher_windows.cpp @@ -0,0 +1,15 @@ +// Copyright 2013 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. +#include "include/url_launcher_windows/url_launcher_windows.h" + +#include + +#include "url_launcher_plugin.h" + +void UrlLauncherWindowsRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + url_launcher_plugin::UrlLauncherPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} From af2896b199ecc3cc5e1ed3000b009f65fc05c9cd Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 18 Aug 2021 06:51:10 -0700 Subject: [PATCH 206/364] [flutter_plugin_tools] Add a command to lint Android code (#4206) Adds a new `lint-android` command to run `gradlew lint` on Android plugins. Also standardizes the names of the Cirrus tasks that run all the build and platform-specific (i.e., not Dart unit test) tests for each platform, as they were getting unnecessarily long and complex in some cases. Fixes https://github.com/flutter/flutter/issues/87071 --- .cirrus.yml | 23 +- packages/android_alarm_manager/CHANGELOG.md | 1 + .../android/build.gradle | 2 + .../android/lint-baseline.xml | 59 +++ packages/android_intent/CHANGELOG.md | 1 + packages/android_intent/android/build.gradle | 1 + packages/battery/battery/CHANGELOG.md | 1 + packages/battery/battery/android/build.gradle | 1 + packages/camera/camera/CHANGELOG.md | 4 + packages/camera/camera/android/build.gradle | 2 + .../camera/camera/android/lint-baseline.xml | 114 +++++ .../connectivity/connectivity/CHANGELOG.md | 1 + .../connectivity/android/build.gradle | 1 + packages/device_info/device_info/CHANGELOG.md | 1 + .../device_info/android/build.gradle | 1 + packages/espresso/CHANGELOG.md | 4 + packages/espresso/android/build.gradle | 2 + packages/espresso/android/lint-baseline.xml | 389 ++++++++++++++++++ .../CHANGELOG.md | 4 + .../android/build.gradle | 1 + .../google_maps_flutter/android/build.gradle | 1 + .../google_sign_in/CHANGELOG.md | 4 + .../google_sign_in/android/build.gradle | 1 + .../image_picker/image_picker/CHANGELOG.md | 4 + .../image_picker/android/build.gradle | 1 + .../in_app_purchase_android/CHANGELOG.md | 8 +- .../android/build.gradle | 1 + packages/local_auth/CHANGELOG.md | 4 + packages/local_auth/android/build.gradle | 2 + packages/local_auth/android/lint-baseline.xml | 59 +++ packages/package_info/CHANGELOG.md | 1 + packages/package_info/android/build.gradle | 1 + .../path_provider/path_provider/CHANGELOG.md | 4 + .../path_provider/android/build.gradle | 1 + .../quick_actions/quick_actions/CHANGELOG.md | 4 + .../quick_actions/android/build.gradle | 1 + packages/sensors/CHANGELOG.md | 1 + packages/sensors/android/build.gradle | 1 + packages/share/CHANGELOG.md | 1 + packages/share/android/build.gradle | 1 + .../shared_preferences/CHANGELOG.md | 1 + .../shared_preferences/android/build.gradle | 2 + .../android/lint-baseline.xml | 81 ++++ .../url_launcher/url_launcher/CHANGELOG.md | 4 + .../url_launcher/android/build.gradle | 1 + .../video_player/video_player/CHANGELOG.md | 4 + .../video_player/android/build.gradle | 1 + .../webview_flutter/CHANGELOG.md | 4 + .../webview_flutter/android/build.gradle | 1 + .../wifi_info_flutter/CHANGELOG.md | 4 + .../wifi_info_flutter/android/build.gradle | 1 + script/tool/CHANGELOG.md | 1 + script/tool/lib/src/common/gradle.dart | 57 +++ script/tool/lib/src/common/xcode.dart | 2 +- .../lib/src/firebase_test_lab_command.dart | 46 +-- script/tool/lib/src/lint_android_command.dart | 61 +++ script/tool/lib/src/main.dart | 2 + script/tool/lib/src/native_test_command.dart | 29 +- script/tool/test/common/gradle_test.dart | 179 ++++++++ .../tool/test/lint_android_command_test.dart | 158 +++++++ 60 files changed, 1306 insertions(+), 47 deletions(-) create mode 100644 packages/android_alarm_manager/android/lint-baseline.xml create mode 100644 packages/camera/camera/android/lint-baseline.xml create mode 100644 packages/espresso/android/lint-baseline.xml create mode 100644 packages/local_auth/android/lint-baseline.xml create mode 100644 packages/shared_preferences/shared_preferences/android/lint-baseline.xml create mode 100644 script/tool/lib/src/common/gradle.dart create mode 100644 script/tool/lib/src/lint_android_command.dart create mode 100644 script/tool/test/common/gradle_test.dart create mode 100644 script/tool/test/lint_android_command_test.dart diff --git a/.cirrus.yml b/.cirrus.yml index ffdd71daebc4..d830a2a15913 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -119,7 +119,7 @@ task: setup_script: - flutter config --enable-linux-desktop << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - - name: build-linux+drive-examples + - name: linux-build+platform-tests env: matrix: CHANNEL: "master" @@ -146,7 +146,7 @@ task: memory: 12G matrix: ### Android tasks ### - - name: build-apks+android-unit+firebase-test-lab + - name: android-build+platform-tests env: matrix: PLUGIN_SHARDING: "--shardIndex 0 --shardCount 4" @@ -165,6 +165,13 @@ task: - export CIRRUS_CHANGE_MESSAGE="" - export CIRRUS_COMMIT_MESSAGE="" - ./script/tool_runner.sh build-examples --apk + lint_script: + # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they + # might include non-ASCII characters which makes Gradle crash. + # TODO(stuartmorgan): See https://github.com/flutter/flutter/issues/24935 + - export CIRRUS_CHANGE_MESSAGE="" + - export CIRRUS_COMMIT_MESSAGE="" + - ./script/tool_runner.sh lint-android # must come after build-examples native_unit_test_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. @@ -186,8 +193,14 @@ task: - else - echo "This user does not have permission to run Firebase Test Lab tests." - fi + # Upload the full lint results to Cirrus to display in the results UI. + always: + android-lint_artifacts: + path: "**/reports/lint-results-debug.xml" + type: text/xml + format: android-lint ### Web tasks ### - - name: build-web+drive-examples + - name: web-build+platform-tests env: matrix: CHANNEL: "master" @@ -220,7 +233,7 @@ task: CHANNEL: "master" CHANNEL: "stable" << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - - name: build-ipas+drive-examples + - name: ios-build+platform-tests env: PATH: $PATH:/usr/local/bin matrix: @@ -256,7 +269,7 @@ task: setup_script: - flutter config --enable-macos-desktop << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - - name: build-macos+drive-examples + - name: macos-build+platform-tests env: matrix: CHANNEL: "master" diff --git a/packages/android_alarm_manager/CHANGELOG.md b/packages/android_alarm_manager/CHANGELOG.md index 71f47cede66e..d53b932e3f0f 100644 --- a/packages/android_alarm_manager/CHANGELOG.md +++ b/packages/android_alarm_manager/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove support for the V1 Android embedding. +* Updated Android lint settings. ## 2.0.2 diff --git a/packages/android_alarm_manager/android/build.gradle b/packages/android_alarm_manager/android/build.gradle index be741097f362..b173137786a9 100644 --- a/packages/android_alarm_manager/android/build.gradle +++ b/packages/android_alarm_manager/android/build.gradle @@ -38,6 +38,8 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } diff --git a/packages/android_alarm_manager/android/lint-baseline.xml b/packages/android_alarm_manager/android/lint-baseline.xml new file mode 100644 index 000000000000..de588614fdb2 --- /dev/null +++ b/packages/android_alarm_manager/android/lint-baseline.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/android_intent/CHANGELOG.md b/packages/android_intent/CHANGELOG.md index 71428c53cea8..82cd5db3e4e4 100644 --- a/packages/android_intent/CHANGELOG.md +++ b/packages/android_intent/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the V1 Android embedding. +* Updated Android lint settings. ## 2.0.2 diff --git a/packages/android_intent/android/build.gradle b/packages/android_intent/android/build.gradle index b0238b7db4f3..e8b9f3810146 100644 --- a/packages/android_intent/android/build.gradle +++ b/packages/android_intent/android/build.gradle @@ -35,6 +35,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/battery/battery/CHANGELOG.md b/packages/battery/battery/CHANGELOG.md index 8590e646564e..ddc912d2ba2a 100644 --- a/packages/battery/battery/CHANGELOG.md +++ b/packages/battery/battery/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the Android v1 embedding. +* Updated Android lint settings. ## 2.0.3 diff --git a/packages/battery/battery/android/build.gradle b/packages/battery/battery/android/build.gradle index 1e484897c2ad..14f503813f7e 100644 --- a/packages/battery/battery/android/build.gradle +++ b/packages/battery/battery/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index d455ddb2fad1..694898092d7a 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 0.8.1+7 * Fix device orientation sometimes not affecting the camera preview orientation. diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle index 6ceed97c9a17..9bbafb653ef8 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -35,6 +35,8 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } compileOptions { sourceCompatibility = '1.8' diff --git a/packages/camera/camera/android/lint-baseline.xml b/packages/camera/camera/android/lint-baseline.xml new file mode 100644 index 000000000000..4ddaafa87988 --- /dev/null +++ b/packages/camera/camera/android/lint-baseline.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/connectivity/connectivity/CHANGELOG.md b/packages/connectivity/connectivity/CHANGELOG.md index 58047482fcb7..f5489692bee9 100644 --- a/packages/connectivity/connectivity/CHANGELOG.md +++ b/packages/connectivity/connectivity/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the Android V1 embedding. +* Updated Android lint settings. ## 3.0.6 diff --git a/packages/connectivity/connectivity/android/build.gradle b/packages/connectivity/connectivity/android/build.gradle index 53a390bd74f0..983f29b142de 100644 --- a/packages/connectivity/connectivity/android/build.gradle +++ b/packages/connectivity/connectivity/android/build.gradle @@ -35,6 +35,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/device_info/device_info/CHANGELOG.md b/packages/device_info/device_info/CHANGELOG.md index 669423cc4efb..97349d450cf1 100644 --- a/packages/device_info/device_info/CHANGELOG.md +++ b/packages/device_info/device_info/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the Android V1 embedding. +* Updated Android lint settings. ## 2.0.2 diff --git a/packages/device_info/device_info/android/build.gradle b/packages/device_info/device_info/android/build.gradle index 51ec2a7fb567..ed89da419d4a 100644 --- a/packages/device_info/device_info/android/build.gradle +++ b/packages/device_info/device_info/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/espresso/CHANGELOG.md b/packages/espresso/CHANGELOG.md index 10e5ae59f71a..e00ea7065ce0 100644 --- a/packages/espresso/CHANGELOG.md +++ b/packages/espresso/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 0.1.0+3 * Remove references to the Android v1 embedding. diff --git a/packages/espresso/android/build.gradle b/packages/espresso/android/build.gradle index 8cd54811afa0..da0cd2ebfee8 100644 --- a/packages/espresso/android/build.gradle +++ b/packages/espresso/android/build.gradle @@ -30,6 +30,8 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } diff --git a/packages/espresso/android/lint-baseline.xml b/packages/espresso/android/lint-baseline.xml new file mode 100644 index 000000000000..19b349f044bf --- /dev/null +++ b/packages/espresso/android/lint-baseline.xml @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md index 6a05ed01e2de..7e567d8cce5c 100644 --- a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md +++ b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 2.0.3 * Remove references to the Android V1 embedding. diff --git a/packages/flutter_plugin_android_lifecycle/android/build.gradle b/packages/flutter_plugin_android_lifecycle/android/build.gradle index ba3a54b235e6..5a584b4e366f 100644 --- a/packages/flutter_plugin_android_lifecycle/android/build.gradle +++ b/packages/flutter_plugin_android_lifecycle/android/build.gradle @@ -31,6 +31,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { diff --git a/packages/google_maps_flutter/google_maps_flutter/android/build.gradle b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle index 6c5ea76ae61e..e3cf6ffe8818 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index e4207de117fa..8ac07ae1793b 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 5.0.7 * Mark iOS arm64 simulators as unsupported. diff --git a/packages/google_sign_in/google_sign_in/android/build.gradle b/packages/google_sign_in/google_sign_in/android/build.gradle index 7d1825defa84..ea98b315f147 100644 --- a/packages/google_sign_in/google_sign_in/android/build.gradle +++ b/packages/google_sign_in/google_sign_in/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 9d89389cb105..4f21ed3cc398 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 0.8.3+2 * Fix using Camera as image source on Android 11+ diff --git a/packages/image_picker/image_picker/android/build.gradle b/packages/image_picker/image_picker/android/build.gradle index 607b3c1523a1..1e6439e6a4eb 100755 --- a/packages/image_picker/image_picker/android/build.gradle +++ b/packages/image_picker/image_picker/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { implementation 'androidx.core:core:1.0.2' diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index d67d1efd61b5..60dae1be6d86 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,8 +1,12 @@ -# 0.1.4+4 +## NEXT + +* Updated Android lint settings. + +## 0.1.4+4 * Removed dependency on the `test` package. -# 0.1.4+3 +## 0.1.4+3 - Updated installation instructions in README. diff --git a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle index 349f9eeb734c..656f7c34bf7a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/packages/local_auth/CHANGELOG.md b/packages/local_auth/CHANGELOG.md index c33fa7778b94..c0d04fb5688a 100644 --- a/packages/local_auth/CHANGELOG.md +++ b/packages/local_auth/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 1.1.7 * Remove references to the Android V1 embedding. diff --git a/packages/local_auth/android/build.gradle b/packages/local_auth/android/build.gradle index 4fcb77cf6c98..dc282e78ced0 100644 --- a/packages/local_auth/android/build.gradle +++ b/packages/local_auth/android/build.gradle @@ -30,6 +30,8 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } diff --git a/packages/local_auth/android/lint-baseline.xml b/packages/local_auth/android/lint-baseline.xml new file mode 100644 index 000000000000..e89eaadb3e6d --- /dev/null +++ b/packages/local_auth/android/lint-baseline.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/package_info/CHANGELOG.md b/packages/package_info/CHANGELOG.md index 2ec20b3fe775..0fe91175cf6b 100644 --- a/packages/package_info/CHANGELOG.md +++ b/packages/package_info/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the Android v1 embedding. +* Updated Android lint settings. ## 2.0.2 diff --git a/packages/package_info/android/build.gradle b/packages/package_info/android/build.gradle index d2846f260556..e21d911ff490 100644 --- a/packages/package_info/android/build.gradle +++ b/packages/package_info/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/path_provider/path_provider/CHANGELOG.md b/packages/path_provider/path_provider/CHANGELOG.md index 5e08c520dcd7..ba7bb3dc7ada 100644 --- a/packages/path_provider/path_provider/CHANGELOG.md +++ b/packages/path_provider/path_provider/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 2.0.3 * Add iOS unit test target. diff --git a/packages/path_provider/path_provider/android/build.gradle b/packages/path_provider/path_provider/android/build.gradle index db2c79c15796..3458140bd0eb 100644 --- a/packages/path_provider/path_provider/android/build.gradle +++ b/packages/path_provider/path_provider/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } android { compileOptions { diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index 5d040f4fd74e..9087c2807061 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 0.6.0+5 * Support only calling initialize once. diff --git a/packages/quick_actions/quick_actions/android/build.gradle b/packages/quick_actions/quick_actions/android/build.gradle index 0bce642f3e60..ec3f84eab4cf 100644 --- a/packages/quick_actions/quick_actions/android/build.gradle +++ b/packages/quick_actions/quick_actions/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { diff --git a/packages/sensors/CHANGELOG.md b/packages/sensors/CHANGELOG.md index 5ac0943333fa..acea470855fb 100644 --- a/packages/sensors/CHANGELOG.md +++ b/packages/sensors/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the Android V1 embedding. +* Updated Android lint settings. ## 2.0.3 diff --git a/packages/sensors/android/build.gradle b/packages/sensors/android/build.gradle index a16ebd2ee459..7e1087764dee 100644 --- a/packages/sensors/android/build.gradle +++ b/packages/sensors/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/share/CHANGELOG.md b/packages/share/CHANGELOG.md index 9074f59f05b7..c9a468d925a7 100644 --- a/packages/share/CHANGELOG.md +++ b/packages/share/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Remove references to the Android V1 embedding. +* Updated Android lint settings. ## 2.0.4 diff --git a/packages/share/android/build.gradle b/packages/share/android/build.gradle index 1b95bf592fb6..b2ea363a3e11 100644 --- a/packages/share/android/build.gradle +++ b/packages/share/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { diff --git a/packages/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index 3476f4eff3f0..48abf9ad4045 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Add iOS unit test target. +* Updated Android lint settings. ## 2.0.6 diff --git a/packages/shared_preferences/shared_preferences/android/build.gradle b/packages/shared_preferences/shared_preferences/android/build.gradle index 6a66eba508fb..9284f1c36143 100644 --- a/packages/shared_preferences/shared_preferences/android/build.gradle +++ b/packages/shared_preferences/shared_preferences/android/build.gradle @@ -38,6 +38,8 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } dependencies { testImplementation 'junit:junit:4.12' diff --git a/packages/shared_preferences/shared_preferences/android/lint-baseline.xml b/packages/shared_preferences/shared_preferences/android/lint-baseline.xml new file mode 100644 index 000000000000..6b2f35f5a151 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/android/lint-baseline.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index dc67a2142ec2..237f0b139475 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 6.0.10 * Remove references to the Android v1 embedding. diff --git a/packages/url_launcher/url_launcher/android/build.gradle b/packages/url_launcher/url_launcher/android/build.gradle index 5dd7e773a1ca..d374d40534c3 100644 --- a/packages/url_launcher/url_launcher/android/build.gradle +++ b/packages/url_launcher/url_launcher/android/build.gradle @@ -30,6 +30,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index f2029622f0ee..f07bb5f66f8c 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 2.1.14 * Removed dependency on the `flutter_test` package. diff --git a/packages/video_player/video_player/android/build.gradle b/packages/video_player/video_player/android/build.gradle index f2f18bff9798..9d9984439370 100644 --- a/packages/video_player/video_player/android/build.gradle +++ b/packages/video_player/video_player/android/build.gradle @@ -35,6 +35,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } android { compileOptions { diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index df7d9cb87457..361bfd24f3af 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 2.0.12 * Improved the documentation on using the different Android Platform View modes. diff --git a/packages/webview_flutter/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle index cd1b4188a1eb..4a164317c60f 100644 --- a/packages/webview_flutter/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/webview_flutter/android/build.gradle @@ -31,6 +31,7 @@ android { lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { diff --git a/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md b/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md index 925745faa22a..86f3f67af103 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md +++ b/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated Android lint settings. + ## 2.0.2 * Update README to point to Plus Plugins version. diff --git a/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle b/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle index 2b5a8a7fc209..661ee82da4d0 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle +++ b/packages/wifi_info_flutter/wifi_info_flutter/android/build.gradle @@ -29,6 +29,7 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 267019fe7359..87917d63d3fc 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT - Added Android native integration test support to `native-test`. +- Added a new `android-lint` command to lint Android plugin native code. ## 0.5.0 diff --git a/script/tool/lib/src/common/gradle.dart b/script/tool/lib/src/common/gradle.dart new file mode 100644 index 000000000000..e7214bf29714 --- /dev/null +++ b/script/tool/lib/src/common/gradle.dart @@ -0,0 +1,57 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:platform/platform.dart'; + +import 'process_runner.dart'; + +const String _gradleWrapperWindows = 'gradlew.bat'; +const String _gradleWrapperNonWindows = 'gradlew'; + +/// A utility class for interacting with Gradle projects. +class GradleProject { + /// Creates an instance that runs commands for [project] with the given + /// [processRunner]. + /// + /// If [log] is true, commands run by this instance will long various status + /// messages. + GradleProject( + this.flutterProject, { + this.processRunner = const ProcessRunner(), + this.platform = const LocalPlatform(), + }); + + /// The directory of a Flutter project to run Gradle commands in. + final Directory flutterProject; + + /// The [ProcessRunner] used to run commands. Overridable for testing. + final ProcessRunner processRunner; + + /// The platform that commands are being run on. + final Platform platform; + + /// The project's 'android' directory. + Directory get androidDirectory => flutterProject.childDirectory('android'); + + /// The path to the Gradle wrapper file for the project. + File get gradleWrapper => androidDirectory.childFile( + platform.isWindows ? _gradleWrapperWindows : _gradleWrapperNonWindows); + + /// Whether or not the project is ready to have Gradle commands run on it + /// (i.e., whether the `flutter` tool has generated the necessary files). + bool isConfigured() => gradleWrapper.existsSync(); + + /// Runs a `gradlew` command with the given parameters. + Future runCommand( + String target, { + List arguments = const [], + }) { + return processRunner.runAndStream( + gradleWrapper.path, + [target, ...arguments], + workingDir: androidDirectory, + ); + } +} diff --git a/script/tool/lib/src/common/xcode.dart b/script/tool/lib/src/common/xcode.dart index d6bbae419eda..83f681bcb492 100644 --- a/script/tool/lib/src/common/xcode.dart +++ b/script/tool/lib/src/common/xcode.dart @@ -15,7 +15,7 @@ const String _xcRunCommand = 'xcrun'; /// A utility class for interacting with the installed version of Xcode. class Xcode { - /// Creates an instance that runs commends with the given [processRunner]. + /// Creates an instance that runs commands with the given [processRunner]. /// /// If [log] is true, commands run by this instance will long various status /// messages. diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 8459f6c70153..fd2de97be4b3 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -10,6 +10,7 @@ import 'package:platform/platform.dart'; import 'package:uuid/uuid.dart'; import 'common/core.dart'; +import 'common/gradle.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; @@ -74,8 +75,6 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { 'Runs tests in test_instrumentation folder using the ' 'instrumentation_test package.'; - static const String _gradleWrapper = 'gradlew'; - bool _firebaseProjectConfigured = false; Future _configureFirebaseProject() async { @@ -138,13 +137,15 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { } // Ensures that gradle wrapper exists - if (!await _ensureGradleWrapperExists(androidDirectory)) { + final GradleProject project = GradleProject(exampleDirectory, + processRunner: processRunner, platform: platform); + if (!await _ensureGradleWrapperExists(project)) { return PackageResult.fail(['Unable to build example apk']); } await _configureFirebaseProject(); - if (!await _runGradle(androidDirectory, 'app:assembleAndroidTest')) { + if (!await _runGradle(project, 'app:assembleAndroidTest')) { return PackageResult.fail(['Unable to assemble androidTest']); } @@ -156,8 +157,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { for (final File test in _findIntegrationTestFiles(package)) { final String testName = getRelativePosixPath(test, from: package); print('Testing $testName...'); - if (!await _runGradle(androidDirectory, 'app:assembleDebug', - testFile: test)) { + if (!await _runGradle(project, 'app:assembleDebug', testFile: test)) { printError('Could not build $testName'); errors.add('$testName failed to build'); continue; @@ -204,12 +204,12 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { : PackageResult.fail(errors); } - /// Checks that 'gradlew' exists in [androidDirectory], and if not runs a + /// Checks that Gradle has been configured for [project], and if not runs a /// Flutter build to generate it. /// /// Returns true if either gradlew was already present, or the build succeeds. - Future _ensureGradleWrapperExists(Directory androidDirectory) async { - if (!androidDirectory.childFile(_gradleWrapper).existsSync()) { + Future _ensureGradleWrapperExists(GradleProject project) async { + if (!project.isConfigured()) { print('Running flutter build apk...'); final String experiment = getStringArg(kEnableExperiment); final int exitCode = await processRunner.runAndStream( @@ -219,7 +219,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { 'apk', if (experiment.isNotEmpty) '--enable-experiment=$experiment', ], - workingDir: androidDirectory); + workingDir: project.androidDirectory); if (exitCode != 0) { return false; @@ -228,15 +228,15 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { return true; } - /// Builds [target] using 'gradlew' in the given [directory]. Assumes - /// 'gradlew' already exists. + /// Builds [target] using Gradle in the given [project]. Assumes Gradle is + /// already configured. /// /// [testFile] optionally does the Flutter build with the given test file as /// the build target. /// /// Returns true if the command succeeds. Future _runGradle( - Directory directory, + GradleProject project, String target, { File? testFile, }) async { @@ -245,17 +245,15 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { ? Uri.encodeComponent('--enable-experiment=$experiment') : null; - final int exitCode = await processRunner.runAndStream( - directory.childFile(_gradleWrapper).path, - [ - target, - '-Pverbose=true', - if (testFile != null) '-Ptarget=${testFile.path}', - if (extraOptions != null) '-Pextra-front-end-options=$extraOptions', - if (extraOptions != null) - '-Pextra-gen-snapshot-options=$extraOptions', - ], - workingDir: directory); + final int exitCode = await project.runCommand( + target, + arguments: [ + '-Pverbose=true', + if (testFile != null) '-Ptarget=${testFile.path}', + if (extraOptions != null) '-Pextra-front-end-options=$extraOptions', + if (extraOptions != null) '-Pextra-gen-snapshot-options=$extraOptions', + ], + ); if (exitCode != 0) { return false; diff --git a/script/tool/lib/src/lint_android_command.dart b/script/tool/lib/src/lint_android_command.dart new file mode 100644 index 000000000000..be6c6ed32415 --- /dev/null +++ b/script/tool/lib/src/lint_android_command.dart @@ -0,0 +1,61 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:platform/platform.dart'; + +import 'common/core.dart'; +import 'common/gradle.dart'; +import 'common/package_looping_command.dart'; +import 'common/process_runner.dart'; + +/// Lint the CocoaPod podspecs and run unit tests. +/// +/// See https://guides.cocoapods.org/terminal/commands.html#pod_lib_lint. +class LintAndroidCommand extends PackageLoopingCommand { + /// Creates an instance of the linter command. + LintAndroidCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + }) : super(packagesDir, processRunner: processRunner, platform: platform); + + @override + final String name = 'lint-android'; + + @override + final String description = 'Runs "gradlew lint" on Android plugins.\n\n' + 'Requires the example to have been build at least once before running.'; + + @override + Future runForPackage(Directory package) async { + if (!pluginSupportsPlatform(kPlatformAndroid, package, + requiredMode: PlatformSupport.inline)) { + return PackageResult.skip( + 'Plugin does not have an Android implemenatation.'); + } + + final Directory exampleDirectory = package.childDirectory('example'); + final GradleProject project = GradleProject(exampleDirectory, + processRunner: processRunner, platform: platform); + + if (!project.isConfigured()) { + return PackageResult.fail(['Build example before linting']); + } + + final String packageName = package.basename; + + // Only lint one build mode to avoid extra work. + // Only lint the plugin project itself, to avoid failing due to errors in + // dependencies. + // + // TODO(stuartmorgan): Consider adding an XML parser to read and summarize + // all results. Currently, only the first three errors will be shown inline, + // and the rest have to be checked via the CI-uploaded artifact. + final int exitCode = await project.runCommand('$packageName:lintDebug'); + + return exitCode == 0 ? PackageResult.success() : PackageResult.fail(); + } +} diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index 6001c5df7f0a..e70cba24cc5e 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -16,6 +16,7 @@ import 'drive_examples_command.dart'; import 'firebase_test_lab_command.dart'; import 'format_command.dart'; import 'license_check_command.dart'; +import 'lint_android_command.dart'; import 'lint_podspecs_command.dart'; import 'list_command.dart'; import 'native_test_command.dart'; @@ -51,6 +52,7 @@ void main(List args) { ..addCommand(FirebaseTestLabCommand(packagesDir)) ..addCommand(FormatCommand(packagesDir)) ..addCommand(LicenseCheckCommand(packagesDir)) + ..addCommand(LintAndroidCommand(packagesDir)) ..addCommand(LintPodspecsCommand(packagesDir)) ..addCommand(ListCommand(packagesDir)) ..addCommand(NativeTestCommand(packagesDir)) diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 9fc6a2912ccc..0bd2ab45f634 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -6,6 +6,7 @@ import 'package:file/file.dart'; import 'package:platform/platform.dart'; import 'common/core.dart'; +import 'common/gradle.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; @@ -47,8 +48,6 @@ class NativeTestCommand extends PackageLoopingCommand { help: 'Runs native integration (UI) tests', defaultsTo: true); } - static const String _gradleWrapper = 'gradlew'; - // The device destination flags for iOS tests. List _iosDestinationFlags = []; @@ -243,9 +242,12 @@ this command. final String exampleName = getPackageDescription(example); _printRunningExampleTestsMessage(example, 'Android'); - final Directory androidDirectory = example.childDirectory('android'); - final File gradleFile = androidDirectory.childFile(_gradleWrapper); - if (!gradleFile.existsSync()) { + final GradleProject project = GradleProject( + example, + processRunner: processRunner, + platform: platform, + ); + if (!project.isConfigured()) { printError('ERROR: Run "flutter build apk" on $exampleName, or run ' 'this tool\'s "build-examples --apk" command, ' 'before executing tests.'); @@ -256,9 +258,7 @@ this command. if (runUnitTests) { print('Running unit tests...'); - final int exitCode = await processRunner.runAndStream( - gradleFile.path, ['testDebugUnitTest'], - workingDir: androidDirectory); + final int exitCode = await project.runCommand('testDebugUnitTest'); if (exitCode != 0) { printError('$exampleName unit tests failed.'); failed = true; @@ -275,13 +275,12 @@ this command. 'notAnnotation=io.flutter.plugins.DartIntegrationTest'; print('Running integration tests...'); - final int exitCode = await processRunner.runAndStream( - gradleFile.path, - [ - 'app:connectedAndroidTest', - '-Pandroid.testInstrumentationRunnerArguments.$filter', - ], - workingDir: androidDirectory); + final int exitCode = await project.runCommand( + 'app:connectedAndroidTest', + arguments: [ + '-Pandroid.testInstrumentationRunnerArguments.$filter', + ], + ); if (exitCode != 0) { printError('$exampleName integration tests failed.'); failed = true; diff --git a/script/tool/test/common/gradle_test.dart b/script/tool/test/common/gradle_test.dart new file mode 100644 index 000000000000..c24887d3d469 --- /dev/null +++ b/script/tool/test/common/gradle_test.dart @@ -0,0 +1,179 @@ +// Copyright 2013 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:io' as io; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/gradle.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import '../util.dart'; + +void main() { + late FileSystem fileSystem; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(); + processRunner = RecordingProcessRunner(); + }); + + group('isConfigured', () { + test('reports true when configured on Windows', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/gradlew.bat']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isWindows: true), + ); + + expect(project.isConfigured(), true); + }); + + test('reports true when configured on non-Windows', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/gradlew']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isMacOS: true), + ); + + expect(project.isConfigured(), true); + }); + + test('reports false when not configured on Windows', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/foo']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isWindows: true), + ); + + expect(project.isConfigured(), false); + }); + + test('reports true when configured on non-Windows', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/foo']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isMacOS: true), + ); + + expect(project.isConfigured(), false); + }); + }); + + group('runXcodeBuild', () { + test('runs without arguments', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/gradlew']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isMacOS: true), + ); + + final int exitCode = await project.runCommand('foo'); + + expect(exitCode, 0); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + plugin.childDirectory('android').childFile('gradlew').path, + const [ + 'foo', + ], + plugin.childDirectory('android').path), + ])); + }); + + test('runs with arguments', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/gradlew']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isMacOS: true), + ); + + final int exitCode = await project.runCommand( + 'foo', + arguments: ['--bar', '--baz'], + ); + + expect(exitCode, 0); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + plugin.childDirectory('android').childFile('gradlew').path, + const [ + 'foo', + '--bar', + '--baz', + ], + plugin.childDirectory('android').path), + ])); + }); + + test('runs with the correct wrapper on Windows', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/gradlew.bat']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isWindows: true), + ); + + final int exitCode = await project.runCommand('foo'); + + expect(exitCode, 0); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + plugin.childDirectory('android').childFile('gradlew.bat').path, + const [ + 'foo', + ], + plugin.childDirectory('android').path), + ])); + }); + + test('returns error codes', () async { + final Directory plugin = createFakePlugin( + 'plugin', fileSystem.directory('/'), + extraFiles: ['android/gradlew.bat']); + final GradleProject project = GradleProject( + plugin, + processRunner: processRunner, + platform: MockPlatform(isWindows: true), + ); + + processRunner.mockProcessesForExecutable[project.gradleWrapper.path] = + [ + MockProcess.failing(), + ]; + + final int exitCode = await project.runCommand('foo'); + + expect(exitCode, 1); + }); + }); +} diff --git a/script/tool/test/lint_android_command_test.dart b/script/tool/test/lint_android_command_test.dart new file mode 100644 index 000000000000..05ead220c15b --- /dev/null +++ b/script/tool/test/lint_android_command_test.dart @@ -0,0 +1,158 @@ +// Copyright 2013 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:io' as io; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:flutter_plugin_tools/src/lint_android_command.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +void main() { + group('$LintAndroidCommand', () { + FileSystem fileSystem; + late Directory packagesDir; + late CommandRunner runner; + late MockPlatform mockPlatform; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(style: FileSystemStyle.posix); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + mockPlatform = MockPlatform(); + processRunner = RecordingProcessRunner(); + final LintAndroidCommand command = LintAndroidCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); + + runner = CommandRunner( + 'lint_android_test', 'Test for $LintAndroidCommand'); + runner.addCommand(command); + }); + + test('runs gradle lint', () async { + final Directory pluginDir = + createFakePlugin('plugin1', packagesDir, extraFiles: [ + 'example/android/gradlew', + ], platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); + + final Directory androidDir = + pluginDir.childDirectory('example').childDirectory('android'); + + final List output = + await runCapturingPrint(runner, ['lint-android']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + androidDir.childFile('gradlew').path, + const ['plugin1:lintDebug'], + androidDir.path, + ), + ]), + ); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin1'), + contains('No issues found!'), + ])); + }); + + test('fails if gradlew is missing', () async { + createFakePlugin('plugin1', packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['lint-android'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder( + [ + contains('Build example before linting'), + ], + )); + }); + + test('fails if linting finds issues', () async { + createFakePlugin('plugin1', packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.inline + }); + + processRunner.mockProcessesForExecutable['gradlew'] = [ + MockProcess.failing(), + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['lint-android'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder( + [ + contains('Build example before linting'), + ], + )); + }); + + test('skips non-Android plugins', () async { + createFakePlugin('plugin1', packagesDir); + + final List output = + await runCapturingPrint(runner, ['lint-android']); + + expect( + output, + containsAllInOrder( + [ + contains( + 'SKIPPING: Plugin does not have an Android implemenatation.') + ], + )); + }); + + test('skips non-inline plugins', () async { + createFakePlugin('plugin1', packagesDir, + platformSupport: { + kPlatformAndroid: PlatformSupport.federated + }); + + final List output = + await runCapturingPrint(runner, ['lint-android']); + + expect( + output, + containsAllInOrder( + [ + contains( + 'SKIPPING: Plugin does not have an Android implemenatation.') + ], + )); + }); + }); +} From a22f5912f6ba5f6b15378172c507388991d50de3 Mon Sep 17 00:00:00 2001 From: Andrew Zuo Date: Thu, 19 Aug 2021 10:16:27 -0400 Subject: [PATCH 207/364] [in_app_purchase] Add toString() to IAPError (#4162) This adds toString() to the IAPError class. This is so it is easier to see what is causing IAPError's in error logs. --- .../CHANGELOG.md | 4 +++ .../lib/src/errors/errors.dart | 1 + .../in_app_purchase_error.dart | 5 ++++ .../src/types/product_details_response.dart | 2 +- .../lib/src/types/purchase_details.dart | 2 +- .../lib/src/types/types.dart | 1 - .../pubspec.yaml | 2 +- .../errors/in_app_purchase_error_test.dart | 29 +++++++++++++++++++ 8 files changed, 42 insertions(+), 4 deletions(-) rename packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/{types => errors}/in_app_purchase_error.dart (88%) create mode 100644 packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_error_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md index ec619d2fdc37..cd4b86d7f39a 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.2.0 + +* Added `toString()` to `IAPError` + ## 1.1.0 * Added `currencySymbol` in ProductDetails. diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.dart index 7b788aaef490..8e10997aaedc 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.dart @@ -2,4 +2,5 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'in_app_purchase_error.dart'; export 'in_app_purchase_exception.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/in_app_purchase_error.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_error.dart similarity index 88% rename from packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/in_app_purchase_error.dart rename to packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_error.dart index f305f578f54a..166646d35b24 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/in_app_purchase_error.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_error.dart @@ -28,4 +28,9 @@ class IAPError { /// Error details, possibly null. final dynamic details; + + @override + String toString() { + return 'IAPError(code: $code, source: $source, message: $message, details: $details)'; + } } diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart index 11b244a84ae3..3a9d7c3c976e 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'in_app_purchase_error.dart'; +import '../errors/in_app_purchase_error.dart'; import 'product_details.dart'; /// The response returned by [InAppPurchasePlatform.queryProductDetails]. diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_details.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_details.dart index 08d0efe09878..8c98beb591ef 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_details.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'in_app_purchase_error.dart'; +import '../errors/in_app_purchase_error.dart'; import 'purchase_status.dart'; import 'purchase_verification_data.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/types.dart index 33d183c51d04..7cb666408249 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/types.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/types.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -export 'in_app_purchase_error.dart'; export 'product_details.dart'; export 'product_details_response.dart'; export 'purchase_details.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml index d15e5f40fc6f..64574e0cf306 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purch issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 1.1.0 +version: 1.2.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_error_test.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_error_test.dart new file mode 100644 index 000000000000..ed63f495b4c2 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_error_test.dart @@ -0,0 +1,29 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_platform_interface/src/errors/in_app_purchase_error.dart'; + +void main() { + test('toString: Should return a description of the error', () { + final IAPError exceptionNoDetails = IAPError( + code: 'error_code', + message: 'dummy_message', + source: 'dummy_source', + ); + + expect(exceptionNoDetails.toString(), + 'IAPError(code: error_code, source: dummy_source, message: dummy_message, details: null)'); + + final IAPError exceptionWithDetails = IAPError( + code: 'error_code', + message: 'dummy_message', + source: 'dummy_source', + details: 'dummy_details', + ); + + expect(exceptionWithDetails.toString(), + 'IAPError(code: error_code, source: dummy_source, message: dummy_message, details: dummy_details)'); + }); +} From f93314bb3779ebb0151bc326a0e515ca5f46533c Mon Sep 17 00:00:00 2001 From: Yusuf <82844127+ydag@users.noreply.github.com> Date: Fri, 20 Aug 2021 16:17:06 +0200 Subject: [PATCH 208/364] [image_picker] Platform interface update cache (#4123) --- .../CHANGELOG.md | 4 ++++ .../method_channel_image_picker.dart | 14 +++++++++++++- .../image_picker_platform.dart | 6 +++--- .../lib/src/types/lost_data_response.dart | 17 +++++++++++++++-- .../pubspec.yaml | 2 +- .../new_method_channel_image_picker_test.dart | 16 ++++++++++++++++ 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md index bd56f0ca77a6..97480e044284 100644 --- a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md +++ b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.3.0 + +* Updated `LostDataResponse` to include a `files` property, in case more than one file was recovered. + ## 2.2.0 * Added new methods that return `XFile` (from `package:cross_file`) diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart index bb9e18e78d83..292cb814ddeb 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart @@ -227,7 +227,9 @@ class MethodChannelImagePicker extends ImagePickerPlatform { @override Future getLostData() async { - final Map? result = + List? pickedFileList; + + Map? result = await _channel.invokeMapMethod('retrieve'); if (result == null) { @@ -254,10 +256,20 @@ class MethodChannelImagePicker extends ImagePickerPlatform { final String? path = result['path']; + final pathList = result['pathList']; + if (pathList != null) { + pickedFileList = []; + // In this case, multiRetrieve is invoked. + for (String path in pathList) { + pickedFileList.add(XFile(path)); + } + } + return LostDataResponse( file: path != null ? XFile(path) : null, exception: exception, type: retrieveType, + files: pickedFileList, ); } } diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart index 8f9ab99eae06..5c1c8b698442 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart @@ -128,7 +128,8 @@ abstract class ImagePickerPlatform extends PlatformInterface { throw UnimplementedError('pickVideo() has not been implemented.'); } - /// Retrieve the lost [PickedFile] file when [pickImage] or [pickVideo] failed because the MainActivity is destroyed. (Android only) + /// Retrieves any previously picked file, that was lost due to the MainActivity being destroyed. + /// In case multiple files were lost, only the last file will be recovered. (Android only). /// /// Image or video can be lost if the MainActivity is destroyed. And there is no guarantee that the MainActivity is always alive. /// Call this method to retrieve the lost data and process the data according to your APP's business logic. @@ -233,8 +234,7 @@ abstract class ImagePickerPlatform extends PlatformInterface { throw UnimplementedError('getVideo() has not been implemented.'); } - /// Retrieve the lost [XFile] file when [getImage], [getMultiImage] or [getVideo] failed because the MainActivity is - /// destroyed. (Android only) + /// Retrieves any previously picked files, that were lost due to the MainActivity being destroyed. (Android only) /// /// Image or video can be lost if the MainActivity is destroyed. And there is no guarantee that the MainActivity is /// always alive. Call this method to retrieve the lost data and process the data according to your APP's business logic. diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart index 576ad334bd35..65f5d7e15c90 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart @@ -14,7 +14,12 @@ import 'package:image_picker_platform_interface/src/types/types.dart'; class LostDataResponse { /// Creates an instance with the given [file], [exception], and [type]. Any of /// the params may be null, but this is never considered to be empty. - LostDataResponse({this.file, this.exception, this.type}); + LostDataResponse({ + this.file, + this.exception, + this.type, + this.files, + }); /// Initializes an instance with all member params set to null and considered /// to be empty. @@ -22,7 +27,8 @@ class LostDataResponse { : file = null, exception = null, type = null, - _empty = true; + _empty = true, + files = null; /// Whether it is an empty response. /// @@ -50,4 +56,11 @@ class LostDataResponse { final RetrieveType? type; bool _empty = false; + + /// The list of files that were lost in a previous [getMultiImage] call due to MainActivity being destroyed. + /// + /// When [files] is populated, [file] will refer to the last item in the [files] list. + /// + /// Can be null if [exception] exists. + final List? files; } diff --git a/packages/image_picker/image_picker_platform_interface/pubspec.yaml b/packages/image_picker/image_picker_platform_interface/pubspec.yaml index 0953e76f03ee..2168ff0f778a 100644 --- a/packages/image_picker/image_picker_platform_interface/pubspec.yaml +++ b/packages/image_picker/image_picker_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.2.0 +version: 2.3.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart index e5321abc0121..17caa8456621 100644 --- a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart +++ b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart @@ -929,6 +929,22 @@ void main() { expect(response.file!.path, '/example/path'); }); + test('getLostData should successfully retrieve multiple files', () async { + picker.channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'image', + 'path': '/example/path1', + 'pathList': ['/example/path0', '/example/path1'], + }; + }); + final LostDataResponse response = await picker.getLostData(); + expect(response.type, RetrieveType.image); + expect(response.file, isNotNull); + expect(response.file!.path, '/example/path1'); + expect(response.files!.first.path, '/example/path0'); + expect(response.files!.length, 2); + }); + test('getLostData get error response', () async { picker.channel.setMockMethodCallHandler((MethodCall methodCall) async { return { From 5ea96e51de83ae5cfcfecf5ae4af15fdd5f8c9b8 Mon Sep 17 00:00:00 2001 From: Cobinja Date: Fri, 20 Aug 2021 16:57:08 +0200 Subject: [PATCH 209/364] [shared_preferences] Fix possible clash of string with double entry (#3895) --- .../shared_preferences/CHANGELOG.md | 3 +- .../MethodCallHandlerImpl.java | 4 ++- .../shared_preferences_test.dart | 36 +++++++++++++++++++ .../lib/shared_preferences.dart | 7 ++++ .../shared_preferences/pubspec.yaml | 2 +- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index 48abf9ad4045..57b35a81255b 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,7 +1,8 @@ -## NEXT +## 2.0.7 * Add iOS unit test target. * Updated Android lint settings. +* Fix string clash with double entries on Android ## 2.0.6 diff --git a/packages/shared_preferences/shared_preferences/android/src/main/java/io/flutter/plugins/sharedpreferences/MethodCallHandlerImpl.java b/packages/shared_preferences/shared_preferences/android/src/main/java/io/flutter/plugins/sharedpreferences/MethodCallHandlerImpl.java index 71ec14e7d06b..cea3f34b9b96 100644 --- a/packages/shared_preferences/shared_preferences/android/src/main/java/io/flutter/plugins/sharedpreferences/MethodCallHandlerImpl.java +++ b/packages/shared_preferences/shared_preferences/android/src/main/java/io/flutter/plugins/sharedpreferences/MethodCallHandlerImpl.java @@ -86,7 +86,9 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { break; case "setString": String value = (String) call.argument("value"); - if (value.startsWith(LIST_IDENTIFIER) || value.startsWith(BIG_INTEGER_PREFIX)) { + if (value.startsWith(LIST_IDENTIFIER) + || value.startsWith(BIG_INTEGER_PREFIX) + || value.startsWith(DOUBLE_PREFIX)) { result.error( "StorageError", "This string cannot be stored as it clashes with special identifier prefixes.", diff --git a/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart b/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart index 1d46ed5751b0..e8498f473a2c 100644 --- a/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart @@ -3,6 +3,8 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:integration_test/integration_test.dart'; @@ -102,5 +104,39 @@ void main() { // The last write should win. expect(preferences.getInt('int'), writeCount); }); + + testWidgets( + 'string clash with lists, big integers and doubles (Android only)', + (WidgetTester _) async { + await preferences.clear(); + // special prefixes plus a string value + expect( + // prefix for lists + preferences.setString( + 'String', + 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' + + kTestValues2['flutter.String']), + throwsA(isA())); + await preferences.reload(); + expect(preferences.getString('String'), null); + expect( + // prefix for big integers + preferences.setString( + 'String', + 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' + + kTestValues2['flutter.String']), + throwsA(isA())); + await preferences.reload(); + expect(preferences.getString('String'), null); + expect( + // prefix for doubles + preferences.setString( + 'String', + 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' + + kTestValues2['flutter.String']), + throwsA(isA())); + await preferences.reload(); + expect(preferences.getString('String'), null); + }, skip: !Platform.isAndroid); }); } diff --git a/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart b/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart index 3d2dd051f61c..841d615262de 100644 --- a/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart +++ b/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart @@ -128,6 +128,13 @@ class SharedPreferences { _setValue('Double', key, value); /// Saves a string [value] to persistent storage in the background. + /// + /// Note: Due to limitations in Android's SharedPreferences, + /// values cannot start with any one of the following: + /// + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' + /// - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' Future setString(String key, String value) => _setValue('String', key, value); diff --git a/packages/shared_preferences/shared_preferences/pubspec.yaml b/packages/shared_preferences/shared_preferences/pubspec.yaml index c3039a98588b..e3cdfe4f87b3 100644 --- a/packages/shared_preferences/shared_preferences/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.6 +version: 2.0.7 environment: sdk: ">=2.12.0 <3.0.0" From f2b42f78b17ea5edc8f1078aee97a4438f06a6ad Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 20 Aug 2021 07:58:24 -0700 Subject: [PATCH 210/364] Fix and test for 'implements' pubspec entry (#4242) The federated plugin spec calls for implementation packages to include an `implements` entry in the `plugins` section of the `pubspec.yaml` indicating what app-facing package it implements. Most of the described behaviors of the `flutter` tool aren't implemented yet, and the pub.dev features have `default_plugin` as a backstop, so we haven't noticed that they are mostly missing (or in one case, incorrect). To better future-proof the plugins, and to provide a better example to people looking at our plugins as examples of federation, this adds a CI check to make sure that we are correctly adding it, and fixes all of the missing/incorrect values it turned up. Fixes https://github.com/flutter/flutter/issues/88222 --- packages/camera/camera_web/CHANGELOG.md | 4 + packages/camera/camera_web/pubspec.yaml | 4 +- .../connectivity_for_web/CHANGELOG.md | 4 + .../connectivity_for_web/pubspec.yaml | 3 +- .../connectivity_macos/CHANGELOG.md | 3 +- .../connectivity_macos/pubspec.yaml | 4 +- .../file_selector_web/CHANGELOG.md | 4 + .../file_selector_web/pubspec.yaml | 3 +- .../google_maps_flutter_web/CHANGELOG.md | 4 + .../google_maps_flutter_web/pubspec.yaml | 3 +- .../google_sign_in_web/CHANGELOG.md | 4 + .../google_sign_in_web/pubspec.yaml | 3 +- .../image_picker_for_web/CHANGELOG.md | 4 + .../image_picker_for_web/pubspec.yaml | 3 +- .../in_app_purchase_android/CHANGELOG.md | 3 +- .../in_app_purchase_android/pubspec.yaml | 3 +- .../in_app_purchase_ios/CHANGELOG.md | 4 + .../in_app_purchase_ios/pubspec.yaml | 3 +- .../shared_preferences_web/CHANGELOG.md | 4 + .../shared_preferences_web/pubspec.yaml | 3 +- .../url_launcher_web/CHANGELOG.md | 4 + .../url_launcher_web/pubspec.yaml | 3 +- .../video_player_web/CHANGELOG.md | 4 + .../video_player_web/pubspec.yaml | 3 +- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/pubspec_check_command.dart | 80 ++++- .../tool/test/pubspec_check_command_test.dart | 273 +++++++++++++++--- 27 files changed, 369 insertions(+), 69 deletions(-) diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 68bc5f4e1a1e..a481554b540c 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.0+1 + +* Add `implements` to pubspec. + ## 0.1.0 * Initial release diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index c4d78999f273..822af60a979b 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.1.0 +version: 0.1.0+1 # This plugin is under development and will be published # when the first working web camera implementation is added. @@ -30,4 +30,4 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - pedantic: ^1.11.1 \ No newline at end of file + pedantic: ^1.11.1 diff --git a/packages/connectivity/connectivity_for_web/CHANGELOG.md b/packages/connectivity/connectivity_for_web/CHANGELOG.md index ccd689760b84..97e5032c8dd4 100644 --- a/packages/connectivity/connectivity_for_web/CHANGELOG.md +++ b/packages/connectivity/connectivity_for_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.0+1 + +* Add `implements` to pubspec. + ## 0.4.0 * Migrate to null-safety diff --git a/packages/connectivity/connectivity_for_web/pubspec.yaml b/packages/connectivity/connectivity_for_web/pubspec.yaml index 5b05dd80d088..2aaa8bd978fa 100644 --- a/packages/connectivity/connectivity_for_web/pubspec.yaml +++ b/packages/connectivity/connectivity_for_web/pubspec.yaml @@ -2,7 +2,7 @@ name: connectivity_for_web description: An implementation for the web platform of the Flutter `connectivity` plugin. This uses the NetworkInformation Web API, with a fallback to Navigator.onLine. repository: https://github.com/flutter/plugins/tree/master/packages/connectivity/connectivity_for_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+connectivity%22 -version: 0.4.0 +version: 0.4.0+1 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: connectivity platforms: web: pluginClass: ConnectivityPlugin diff --git a/packages/connectivity/connectivity_macos/CHANGELOG.md b/packages/connectivity/connectivity_macos/CHANGELOG.md index c7bc5b4cf469..46a4038f91ee 100644 --- a/packages/connectivity/connectivity_macos/CHANGELOG.md +++ b/packages/connectivity/connectivity_macos/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.2.1+2 * Add Swift language version to podspec. +* Fix `implements` package name in pubspec. ## 0.2.1+1 diff --git a/packages/connectivity/connectivity_macos/pubspec.yaml b/packages/connectivity/connectivity_macos/pubspec.yaml index 1e8842c7417a..b98f23d34eb7 100644 --- a/packages/connectivity/connectivity_macos/pubspec.yaml +++ b/packages/connectivity/connectivity_macos/pubspec.yaml @@ -2,7 +2,7 @@ name: connectivity_macos description: macOS implementation of the connectivity plugin. repository: https://github.com/flutter/plugins/tree/master/packages/connectivity/connectivity_macos issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+connectivity%22 -version: 0.2.1+1 +version: 0.2.1+2 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,7 +10,7 @@ environment: flutter: plugin: - implements: connectivity_platform_interface + implements: connectivity platforms: macos: pluginClass: ConnectivityPlugin diff --git a/packages/file_selector/file_selector_web/CHANGELOG.md b/packages/file_selector/file_selector_web/CHANGELOG.md index dadf5ffdc3fc..e2a863643027 100644 --- a/packages/file_selector/file_selector_web/CHANGELOG.md +++ b/packages/file_selector/file_selector_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1+2 + +* Add `implements` to pubspec. + # 0.8.1+1 - Updated installation instructions in README. diff --git a/packages/file_selector/file_selector_web/pubspec.yaml b/packages/file_selector/file_selector_web/pubspec.yaml index 9753f9216694..bbad45bf2d6b 100644 --- a/packages/file_selector/file_selector_web/pubspec.yaml +++ b/packages/file_selector/file_selector_web/pubspec.yaml @@ -2,7 +2,7 @@ name: file_selector_web description: Web platform implementation of file_selector repository: https://github.com/flutter/plugins/tree/master/packages/file_selector/file_selector_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+file_selector%22 -version: 0.8.1+1 +version: 0.8.1+2 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: file_selector platforms: web: pluginClass: FileSelectorWeb diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index d587c16f9207..83ffe09b357d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.0+4 + +* Add `implements` to pubspec. + ## 0.3.0+3 * Update the `README.md` usage instructions to not be tied to explicit package versions. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index c4323fc6486f..82605f8fd070 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.3.0+3 +version: 0.3.0+4 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: google_maps_flutter platforms: web: pluginClass: GoogleMapsPlugin diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md index 8a2f1dbf56d2..7b9eb6b747ec 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.0+2 + +* Add `implements` to pubspec. + ## 0.10.0+1 * Updated installation instructions in README. diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index 0de229e795ce..7075f43151a6 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.10.0+1 +version: 0.10.0+2 environment: sdk: ">=2.12.0 <3.0.0" @@ -11,6 +11,7 @@ environment: flutter: plugin: + implements: google_sign_in platforms: web: pluginClass: GoogleSignInPlugin diff --git a/packages/image_picker/image_picker_for_web/CHANGELOG.md b/packages/image_picker/image_picker_for_web/CHANGELOG.md index 01d13f900d2d..d11ead3bb64e 100644 --- a/packages/image_picker/image_picker_for_web/CHANGELOG.md +++ b/packages/image_picker/image_picker_for_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.3 + +* Add `implements` to pubspec. + ## 2.1.2 * Updated installation instructions in README. diff --git a/packages/image_picker/image_picker_for_web/pubspec.yaml b/packages/image_picker/image_picker_for_web/pubspec.yaml index 6296992c46d0..895486f3de06 100644 --- a/packages/image_picker/image_picker_for_web/pubspec.yaml +++ b/packages/image_picker/image_picker_for_web/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_for_web description: Web platform implementation of image_picker repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker_for_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 2.1.2 +version: 2.1.3 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: image_picker platforms: web: pluginClass: ImagePickerPlugin diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 60dae1be6d86..8e342a65422c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.1.4+5 +* Add `implements` to pubspec. * Updated Android lint settings. ## 0.1.4+4 diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 3969e34c052b..745b651e5828 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+4 +version: 0.1.4+5 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: in_app_purchase platforms: android: package: io.flutter.plugins.inapppurchase diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index c76409521e2f..e66b5dee6295 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.3+3 + +* Add `implements` to pubspec. + # 0.1.3+2 * Removed dependency on the `test` package. diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 8fc42371f405..07eae3ccc702 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3+2 +version: 0.1.3+3 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: in_app_purchase platforms: ios: pluginClass: InAppPurchasePlugin diff --git a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md index 0a00e7d66a2a..dd68f5321541 100644 --- a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Add `implements` to pubspec. + ## 2.0.1 * Updated installation instructions in README. diff --git a/packages/shared_preferences/shared_preferences_web/pubspec.yaml b/packages/shared_preferences/shared_preferences_web/pubspec.yaml index 2e67be20e427..c878903ac236 100644 --- a/packages/shared_preferences/shared_preferences_web/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_web/pubspec.yaml @@ -2,7 +2,7 @@ name: shared_preferences_web description: Web platform implementation of shared_preferences repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: shared_preferences platforms: web: pluginClass: SharedPreferencesPlugin diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index 64830f5e4481..f5338e62a775 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.4 + +* Add `implements` to pubspec. + ## 2.0.3 - Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index cba098daceb7..77e8068f1396 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_web description: Web platform implementation of url_launcher repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.0.3 +version: 2.0.4 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: url_launcher platforms: web: pluginClass: UrlLauncherPlugin diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index 398ec02ba743..a7a198db21e1 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.3 + +* Add `implements` to pubspec. + ## 2.0.2 * Updated installation instructions in README. diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index f101543598b8..c5eb57c1fc6e 100644 --- a/packages/video_player/video_player_web/pubspec.yaml +++ b/packages/video_player/video_player_web/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_web description: Web platform implementation of video_player. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.0.2 +version: 2.0.3 environment: sdk: ">=2.12.0 <3.0.0" @@ -10,6 +10,7 @@ environment: flutter: plugin: + implements: video_player platforms: web: pluginClass: VideoPlayerPlugin diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 87917d63d3fc..063ae82c386d 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -2,6 +2,7 @@ - Added Android native integration test support to `native-test`. - Added a new `android-lint` command to lint Android plugin native code. +- Pubspec validation now checks for `implements` in implementation packages. ## 0.5.0 diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 539b170dbea1..58aeca1447a8 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -7,6 +7,7 @@ import 'package:git/git.dart'; import 'package:platform/platform.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; +import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; @@ -65,8 +66,8 @@ class PubspecCheckCommand extends PackageLoopingCommand { @override Future runForPackage(Directory package) async { final File pubspec = package.childFile('pubspec.yaml'); - final bool passesCheck = !pubspec.existsSync() || - await _checkPubspec(pubspec, packageName: package.basename); + final bool passesCheck = + !pubspec.existsSync() || await _checkPubspec(pubspec, package: package); if (!passesCheck) { return PackageResult.fail(); } @@ -75,7 +76,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { Future _checkPubspec( File pubspecFile, { - required String packageName, + required Directory package, }) async { final String contents = pubspecFile.readAsStringSync(); final Pubspec? pubspec = _tryParsePubspec(contents); @@ -84,34 +85,43 @@ class PubspecCheckCommand extends PackageLoopingCommand { } final List pubspecLines = contents.split('\n'); - final List sectionOrder = pubspecLines.contains(' plugin:') - ? _majorPluginSections - : _majorPackageSections; + final bool isPlugin = pubspec.flutter?.containsKey('plugin') ?? false; + final List sectionOrder = + isPlugin ? _majorPluginSections : _majorPackageSections; bool passing = _checkSectionOrder(pubspecLines, sectionOrder); if (!passing) { - print('${indentation}Major sections should follow standard ' + printError('${indentation}Major sections should follow standard ' 'repository ordering:'); final String listIndentation = indentation * 2; - print('$listIndentation${sectionOrder.join('\n$listIndentation')}'); + printError('$listIndentation${sectionOrder.join('\n$listIndentation')}'); } if (pubspec.publishTo != 'none') { final List repositoryErrors = - _checkForRepositoryLinkErrors(pubspec, packageName: packageName); + _checkForRepositoryLinkErrors(pubspec, packageName: package.basename); if (repositoryErrors.isNotEmpty) { for (final String error in repositoryErrors) { - print('$indentation$error'); + printError('$indentation$error'); } passing = false; } if (!_checkIssueLink(pubspec)) { - print( + printError( '${indentation}A package should have an "issue_tracker" link to a ' 'search for open flutter/flutter bugs with the relevant label:\n' '${indentation * 2}$_expectedIssueLinkFormat'); passing = false; } + + if (isPlugin) { + final String? error = + _checkForImplementsError(pubspec, package: package); + if (error != null) { + printError('$indentation$error'); + passing = false; + } + } } return passing; @@ -168,4 +178,52 @@ class PubspecCheckCommand extends PackageLoopingCommand { .startsWith(_expectedIssueLinkFormat) == true; } + + // Validates the "implements" keyword for a plugin, returning an error + // string if there are any issues. + // + // Should only be called on plugin packages. + String? _checkForImplementsError( + Pubspec pubspec, { + required Directory package, + }) { + if (_isImplementationPackage(package)) { + final String? implements = + pubspec.flutter!['plugin']!['implements'] as String?; + final String expectedImplements = package.parent.basename; + if (implements == null) { + return 'Missing "implements: $expectedImplements" in "plugin" section.'; + } else if (implements != expectedImplements) { + return 'Expecetd "implements: $expectedImplements"; ' + 'found "implements: $implements".'; + } + } + return null; + } + + // Returns true if [packageName] appears to be an implementation package + // according to repository conventions. + bool _isImplementationPackage(Directory package) { + // An implementation package should be in a group folder... + final Directory parentDir = package.parent; + if (parentDir.path == packagesDir.path) { + return false; + } + final String packageName = package.basename; + final String parentName = parentDir.basename; + // ... whose name is a prefix of the package name. + if (!packageName.startsWith(parentName)) { + return false; + } + // A few known package names are not implementation packages; assume + // anything else is. (This is done instead of listing known implementation + // suffixes to allow for non-standard suffixes; e.g., to put several + // platforms in one package for code-sharing purposes.) + const Set nonImplementationSuffixes = { + '', // App-facing package. + '_platform_interface', // Platform interface package. + }; + final String suffix = packageName.substring(parentName.length); + return !nonImplementationSuffixes.contains(suffix); + } } diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 177ed7f25b4e..a038e0c58fb0 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -66,9 +66,13 @@ environment: '''; } - String flutterSection({bool isPlugin = false}) { - const String pluginEntry = ''' + String flutterSection({ + bool isPlugin = false, + String? implementedPackage, + }) { + final String pluginEntry = ''' plugin: +${implementedPackage == null ? '' : ' implements: $implementedPackage'} platforms: '''; return ''' @@ -177,12 +181,19 @@ ${dependenciesSection()} ${devDependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Found a "homepage" entry; only "repository" should be used.'), + ]), ); }); @@ -197,12 +208,18 @@ ${dependenciesSection()} ${devDependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Missing "repository"'), + ]), ); }); @@ -217,12 +234,19 @@ ${dependenciesSection()} ${devDependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Found a "homepage" entry; only "repository" should be used.'), + ]), ); }); @@ -237,12 +261,18 @@ ${dependenciesSection()} ${devDependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('A package should have an "issue_tracker" link'), + ]), ); }); @@ -257,12 +287,19 @@ ${devDependenciesSection()} ${environmentSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Major sections should follow standard repository ordering:'), + ]), ); }); @@ -277,12 +314,19 @@ ${dependenciesSection()} ${devDependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Major sections should follow standard repository ordering:'), + ]), ); }); @@ -297,12 +341,19 @@ ${devDependenciesSection()} ${dependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Major sections should follow standard repository ordering:'), + ]), ); }); @@ -317,12 +368,150 @@ ${flutterSection(isPlugin: true)} ${dependenciesSection()} '''); - final Future> result = - runCapturingPrint(runner, ['pubspec-check']); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Major sections should follow standard repository ordering:'), + ]), + ); + }); - await expectLater( - result, - throwsA(isA()), + test('fails when an implemenation package is missing "implements"', + () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin_a_foo', packagesDir.childDirectory('plugin_a')); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin_a_foo', isPlugin: true)} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Missing "implements: plugin_a" in "plugin" section.'), + ]), + ); + }); + + test('fails when an implemenation package has the wrong "implements"', + () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin_a_foo', packagesDir.childDirectory('plugin_a')); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin_a_foo', isPlugin: true)} +${environmentSection()} +${flutterSection(isPlugin: true, implementedPackage: 'plugin_a_foo')} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Expecetd "implements: plugin_a"; ' + 'found "implements: plugin_a_foo".'), + ]), + ); + }); + + test('passes for a correct implemenation package', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin_a_foo', packagesDir.childDirectory('plugin_a')); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin_a_foo', isPlugin: true)} +${environmentSection()} +${flutterSection(isPlugin: true, implementedPackage: 'plugin_a')} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + final List output = + await runCapturingPrint(runner, ['pubspec-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin_a_foo...'), + contains('No issues found!'), + ]), + ); + }); + + test('passes for an app-facing package without "implements"', () async { + final Directory pluginDirectory = + createFakePlugin('plugin_a', packagesDir.childDirectory('plugin_a')); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin_a', isPlugin: true)} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + final List output = + await runCapturingPrint(runner, ['pubspec-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin_a/plugin_a...'), + contains('No issues found!'), + ]), + ); + }); + + test('passes for a platform interface package without "implements"', + () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin_a_platform_interface', + packagesDir.childDirectory('plugin_a')); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin_a_platform_interface', isPlugin: true)} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + final List output = + await runCapturingPrint(runner, ['pubspec-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin_a_platform_interface...'), + contains('No issues found!'), + ]), ); }); }); From b1fe1912e016f5566a7b4d171cb06f826bd98bbb Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 20 Aug 2021 11:50:56 -0700 Subject: [PATCH 211/364] [flutter_plugin_tools] Improve 'repository' check (#4244) Ensures that the full relative path in the 'repository' link is correct, not just the last segment. This ensure that path-level errors (e.g., linking to the group directory rather than the package itself for app-facing packages) are caught. Also fixes the errors that this improved check catches, including several cases where a previously unfederated package wasn't fixed when it was moved to a subdirectory. --- .../in_app_purchase/CHANGELOG.md | 4 ++ .../in_app_purchase/pubspec.yaml | 4 +- packages/ios_platform_images/CHANGELOG.md | 3 +- packages/ios_platform_images/pubspec.yaml | 4 +- .../quick_actions/quick_actions/CHANGELOG.md | 3 +- .../quick_actions/quick_actions/pubspec.yaml | 4 +- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/pubspec_check_command.dart | 14 +++-- .../tool/test/pubspec_check_command_test.dart | 61 +++++++++++++++++-- 9 files changed, 81 insertions(+), 17 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 228fcddb6370..95ba4f27d10a 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.8 + +* Fix repository link in pubspec.yaml. + ## 1.0.7 * Remove references to the Android V1 embedding. diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index a37ae07baa86..8b4510b3fce4 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -1,8 +1,8 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. -repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase +repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.7 +version: 1.0.8 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/ios_platform_images/CHANGELOG.md b/packages/ios_platform_images/CHANGELOG.md index 60db21a450d8..a7270eed0576 100644 --- a/packages/ios_platform_images/CHANGELOG.md +++ b/packages/ios_platform_images/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.2.0+1 * Add iOS unit test target. +* Fix repository link in pubspec.yaml. ## 0.2.0 diff --git a/packages/ios_platform_images/pubspec.yaml b/packages/ios_platform_images/pubspec.yaml index e90937f4f0b5..c3938856e386 100644 --- a/packages/ios_platform_images/pubspec.yaml +++ b/packages/ios_platform_images/pubspec.yaml @@ -1,8 +1,8 @@ name: ios_platform_images description: A plugin to share images between Flutter and iOS in add-to-app setups. -repository: https://github.com/flutter/plugins/tree/master/packages/ios_platform_images/ios_platform_images +repository: https://github.com/flutter/plugins/tree/master/packages/ios_platform_images issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+ios_platform_images%22 -version: 0.2.0 +version: 0.2.0+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index 9087c2807061..d893b67b10dc 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.6.0+6 * Updated Android lint settings. +* Fix repository link in pubspec.yaml. ## 0.6.0+5 diff --git a/packages/quick_actions/quick_actions/pubspec.yaml b/packages/quick_actions/quick_actions/pubspec.yaml index e52ab515432f..c5d3fe4d4cbe 100644 --- a/packages/quick_actions/quick_actions/pubspec.yaml +++ b/packages/quick_actions/quick_actions/pubspec.yaml @@ -1,9 +1,9 @@ name: quick_actions description: Flutter plugin for creating shortcuts on home screen, also known as Quick Actions on iOS and App Shortcuts on Android. -repository: https://github.com/flutter/plugins/tree/master/packages/quick_actions +repository: https://github.com/flutter/plugins/tree/master/packages/quick_actions/quick_actions issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+quick_actions%22 -version: 0.6.0+5 +version: 0.6.0+6 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 063ae82c386d..1881d1bb6689 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -3,6 +3,7 @@ - Added Android native integration test support to `native-test`. - Added a new `android-lint` command to lint Android plugin native code. - Pubspec validation now checks for `implements` in implementation packages. +- Pubspec valitation now checks the full relative path of `repository` entries. ## 0.5.0 diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 58aeca1447a8..0a066ab72baf 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -98,7 +98,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { if (pubspec.publishTo != 'none') { final List repositoryErrors = - _checkForRepositoryLinkErrors(pubspec, packageName: package.basename); + _checkForRepositoryLinkErrors(pubspec, package: package); if (repositoryErrors.isNotEmpty) { for (final String error in repositoryErrors) { printError('$indentation$error'); @@ -154,14 +154,18 @@ class PubspecCheckCommand extends PackageLoopingCommand { List _checkForRepositoryLinkErrors( Pubspec pubspec, { - required String packageName, + required Directory package, }) { final List errorMessages = []; if (pubspec.repository == null) { errorMessages.add('Missing "repository"'); - } else if (!pubspec.repository!.path.endsWith(packageName)) { - errorMessages - .add('The "repository" link should end with the package name.'); + } else { + final String relativePackagePath = + path.relative(package.path, from: packagesDir.parent.path); + if (!pubspec.repository!.path.endsWith(relativePackagePath)) { + errorMessages + .add('The "repository" link should end with the package path.'); + } } if (pubspec.homepage != null) { diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index a038e0c58fb0..833f7b601e50 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -37,15 +37,29 @@ void main() { runner.addCommand(command); }); + /// Returns the top section of a pubspec.yaml for a package named [name], + /// for either a flutter/packages or flutter/plugins package depending on + /// the values of [isPlugin]. + /// + /// By default it will create a header that includes all of the expected + /// values, elements can be changed via arguments to create incorrect + /// entries. + /// + /// If [includeRepository] is true, by default the path in the link will + /// be "packages/[name]"; a different "packages"-relative path can be + /// provided with [repositoryPackagesDirRelativePath]. String headerSection( String name, { bool isPlugin = false, bool includeRepository = true, + String? repositoryPackagesDirRelativePath, bool includeHomepage = false, bool includeIssueTracker = true, }) { + final String repositoryPath = repositoryPackagesDirRelativePath ?? name; final String repoLink = 'https://github.com/flutter/' - '${isPlugin ? 'plugins' : 'packages'}/tree/master/packages/$name'; + '${isPlugin ? 'plugins' : 'packages'}/tree/master/' + 'packages/$repositoryPath'; final String issueTrackerLink = 'https://github.com/flutter/flutter/issues?' 'q=is%3Aissue+is%3Aopen+label%3A%22p%3A+$name%22'; @@ -250,6 +264,32 @@ ${devDependenciesSection()} ); }); + test('fails when repository is incorrect', () async { + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin', isPlugin: true, repositoryPackagesDirRelativePath: 'different_plugin')} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The "repository" link should end with the package path.'), + ]), + ); + }); + test('fails when issue tracker is missing', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); @@ -446,7 +486,11 @@ ${devDependenciesSection()} 'plugin_a_foo', packagesDir.childDirectory('plugin_a')); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' -${headerSection('plugin_a_foo', isPlugin: true)} +${headerSection( + 'plugin_a_foo', + isPlugin: true, + repositoryPackagesDirRelativePath: 'plugin_a/plugin_a_foo', + )} ${environmentSection()} ${flutterSection(isPlugin: true, implementedPackage: 'plugin_a')} ${dependenciesSection()} @@ -470,7 +514,11 @@ ${devDependenciesSection()} createFakePlugin('plugin_a', packagesDir.childDirectory('plugin_a')); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' -${headerSection('plugin_a', isPlugin: true)} +${headerSection( + 'plugin_a', + isPlugin: true, + repositoryPackagesDirRelativePath: 'plugin_a/plugin_a', + )} ${environmentSection()} ${flutterSection(isPlugin: true)} ${dependenciesSection()} @@ -496,7 +544,12 @@ ${devDependenciesSection()} packagesDir.childDirectory('plugin_a')); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' -${headerSection('plugin_a_platform_interface', isPlugin: true)} +${headerSection( + 'plugin_a_platform_interface', + isPlugin: true, + repositoryPackagesDirRelativePath: + 'plugin_a/plugin_a_platform_interface', + )} ${environmentSection()} ${flutterSection(isPlugin: true)} ${dependenciesSection()} From 6a8681e7ac18ed625ced9e92c740fb55bd739222 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Mon, 23 Aug 2021 10:57:03 +0200 Subject: [PATCH 212/364] [image_picker] Fix pickImage not returning on iOS when dismissing the PHPicker view by swiping down. (#4228) --- .../image_picker/image_picker/CHANGELOG.md | 3 ++- .../ios/Classes/FLTImagePickerPlugin.m | 24 ++++++++++++++----- .../image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 4f21ed3cc398..a9255976c526 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.8.3+3 +* Fix pickImage not returning a value on iOS when dismissing PHPicker sheet by swiping. * Updated Android lint settings. ## 0.8.3+2 diff --git a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m index 4084ae65b5e0..cf3103195482 100644 --- a/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m +++ b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m @@ -18,7 +18,8 @@ @interface FLTImagePickerPlugin () + PHPickerViewControllerDelegate, + UIAdaptivePresentationControllerDelegate> @property(copy, nonatomic) FlutterResult result; @@ -92,6 +93,7 @@ - (void)pickImageWithPHPicker:(int)maxImagesAllowed API_AVAILABLE(ios(14)) { _pickerViewController = [[PHPickerViewController alloc] initWithConfiguration:config]; _pickerViewController.delegate = self; + _pickerViewController.presentationController.delegate = self; self.maxImagesAllowed = maxImagesAllowed; @@ -373,18 +375,28 @@ - (NSNumber *)getDesiredImageQuality:(NSNumber *)imageQuality { return imageQuality; } +- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { + if (self.result != nil) { + self.result(nil); + self.result = nil; + self->_arguments = nil; + } +} + - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results API_AVAILABLE(ios(14)) { [picker dismissViewControllerAnimated:YES completion:nil]; - dispatch_queue_t backgroundQueue = - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0); - dispatch_async(backgroundQueue, ^{ - if (results.count == 0) { + if (results.count == 0) { + if (self.result != nil) { self.result(nil); self.result = nil; self->_arguments = nil; - return; } + return; + } + dispatch_queue_t backgroundQueue = + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0); + dispatch_async(backgroundQueue, ^{ NSNumber *maxWidth = [self->_arguments objectForKey:@"maxWidth"]; NSNumber *maxHeight = [self->_arguments objectForKey:@"maxHeight"]; NSNumber *imageQuality = [self->_arguments objectForKey:@"imageQuality"]; diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index e167d8ab891c..4becca930261 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.3+2 +version: 0.8.3+3 environment: sdk: ">=2.12.0 <3.0.0" From 0a86ac866b8b322a37d5fac36c7b15856a2b37e8 Mon Sep 17 00:00:00 2001 From: Bodhi Mulders Date: Mon, 23 Aug 2021 18:30:36 +0200 Subject: [PATCH 213/364] [camera] android-rework part 9: Final implementation of camera class (#4059) This PR adds the final implementation for the Camera class that incorporates all the features from previous parts. --- packages/camera/camera/CHANGELOG.md | 5 +- .../io/flutter/plugins/camera/Camera.java | 1275 ++++++++--------- .../flutter/plugins/camera/CameraPlugin.java | 28 +- .../plugins/camera/CameraRegionUtils.java | 29 +- .../flutter/plugins/camera/CameraRegions.java | 76 - .../flutter/plugins/camera/CameraUtils.java | 120 +- .../flutter/plugins/camera/DartMessenger.java | 28 +- .../camera/DeviceOrientationManager.java | 200 --- .../plugins/camera/MethodCallHandlerImpl.java | 40 +- .../plugins/camera/PictureCaptureRequest.java | 96 -- .../camera/features/CameraFeatureFactory.java | 12 +- .../features/CameraFeatureFactoryImpl.java | 11 +- .../camera/features/CameraFeatures.java | 37 + .../exposurepoint/ExposurePointFeature.java | 15 +- .../focuspoint/FocusPointFeature.java | 15 +- .../noisereduction/NoiseReductionFeature.java | 19 +- .../DeviceOrientationManager.java | 192 ++- .../camera/CameraPropertiesImplTest.java | 7 +- ...s_convertPointToMeteringRectangleTest.java | 197 +++ ...aRegionUtils_getCameraBoundariesTest.java} | 120 +- .../io/flutter/plugins/camera/CameraTest.java | 843 +++++++++++ .../plugins/camera/CameraUtilsTest.java | 53 +- .../plugins/camera/CameraZoomTest.java | 18 +- .../plugins/camera/DartMessengerTest.java | 4 +- .../plugins/camera/ImageSaverTests.java | 6 +- .../camera/PictureCaptureRequestTest.java | 152 -- .../autofocus/AutoFocusFeatureTest.java | 24 +- .../features/autofocus/FocusModeTest.java | 6 +- .../exposurelock/ExposureLockFeatureTest.java | 14 +- .../exposurelock/ExposureModeTest.java | 6 +- .../ExposureOffsetFeatureTest.java | 13 +- .../ExposurePointFeatureTest.java | 121 +- .../features/flash/FlashFeatureTest.java | 22 +- .../focuspoint/FocusPointFeatureTest.java | 119 +- .../fpsrange/FpsRangeFeaturePixel4aTest.java | 2 +- .../fpsrange/FpsRangeFeatureTest.java | 12 +- .../NoiseReductionFeatureTest.java | 25 +- .../resolution/ResolutionFeatureTest.java | 22 +- .../DeviceOrientationManagerTest.java | 115 +- .../SensorOrientationFeatureTest.java | 17 +- .../zoomlevel/ZoomLevelFeatureTest.java | 18 +- .../features/zoomlevel/ZoomUtilsTest.java | 8 +- .../media/MediaRecorderBuilderTest.java | 4 +- .../camera/types/ExposureModeTest.java | 6 +- .../plugins/camera/types/FlashModeTest.java | 6 +- .../plugins/camera/types/FocusModeTest.java | 6 +- .../plugins/camera/utils/TestUtils.java | 10 + .../camera/lib/src/camera_controller.dart | 2 +- .../camera/camera/lib/src/camera_preview.dart | 4 +- packages/camera/camera/pubspec.yaml | 3 +- .../camera/test/camera_preview_test.dart | 4 +- .../lib/src/events/device_event.dart | 3 +- .../platform_interface/camera_platform.dart | 3 +- 53 files changed, 2291 insertions(+), 1902 deletions(-) delete mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java delete mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DeviceOrientationManager.java delete mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/PictureCaptureRequest.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_convertPointToMeteringRectangleTest.java rename packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/{CameraRegionUtilsTest.java => CameraRegionUtils_getCameraBoundariesTest.java} (61%) create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java delete mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/PictureCaptureRequestTest.java diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 694898092d7a..68188d6510ff 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,5 +1,8 @@ -## NEXT +## 0.9.0 +* Complete rewrite of Android plugin to fix many capture, focus, flash, orientation and exposure issues. +* Fixed crash when opening front-facing cameras on some legacy android devices like Sony XZ. +* Android Flash mode works with full precapture sequence. * Updated Android lint settings. ## 0.8.1+7 diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index 4c1370f2f3cb..4724d22a1bcd 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -4,27 +4,19 @@ package io.flutter.plugins.camera; -import static io.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; - import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.graphics.ImageFormat; -import android.graphics.Rect; import android.graphics.SurfaceTexture; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCaptureSession; -import android.hardware.camera2.CameraCaptureSession.CaptureCallback; -import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraDevice; import android.hardware.camera2.CameraManager; import android.hardware.camera2.CameraMetadata; -import android.hardware.camera2.CaptureFailure; import android.hardware.camera2.CaptureRequest; -import android.hardware.camera2.CaptureResult; import android.hardware.camera2.TotalCaptureResult; -import android.hardware.camera2.params.MeteringRectangle; import android.hardware.camera2.params.OutputConfiguration; import android.hardware.camera2.params.SessionConfiguration; import android.media.CamcorderProfile; @@ -35,27 +27,43 @@ import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Handler; +import android.os.HandlerThread; import android.os.Looper; -import android.os.SystemClock; import android.util.Log; -import android.util.Range; -import android.util.Rational; import android.util.Size; +import android.view.Display; import android.view.Surface; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.OnLifecycleEvent; import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugins.camera.PictureCaptureRequest.State; +import io.flutter.plugins.camera.features.CameraFeature; +import io.flutter.plugins.camera.features.CameraFeatureFactory; +import io.flutter.plugins.camera.features.CameraFeatures; +import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.autofocus.FocusMode; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureMode; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.flash.FlashMode; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.DeviceOrientationManager; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; import io.flutter.plugins.camera.media.MediaRecorderBuilder; -import io.flutter.plugins.camera.types.ExposureMode; -import io.flutter.plugins.camera.types.FlashMode; -import io.flutter.plugins.camera.types.FocusMode; -import io.flutter.plugins.camera.types.ResolutionPreset; +import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -71,151 +79,173 @@ interface ErrorCallback { void onError(String errorCode, String errorMessage); } -public class Camera { +class Camera + implements CameraCaptureCallback.CameraCaptureStateListener, + ImageReader.OnImageAvailableListener, + LifecycleObserver { private static final String TAG = "Camera"; - /** Timeout for the pre-capture sequence. */ - private static final long PRECAPTURE_TIMEOUT_MS = 1000; + private static final HashMap supportedImageFormats; + + // Current supported outputs. + static { + supportedImageFormats = new HashMap<>(); + supportedImageFormats.put("yuv420", ImageFormat.YUV_420_888); + supportedImageFormats.put("jpeg", ImageFormat.JPEG); + } + + /** + * Holds all of the camera features/settings and will be used to update the request builder when + * one changes. + */ + private final CameraFeatures cameraFeatures; private final SurfaceTextureEntry flutterTexture; - private final CameraManager cameraManager; - private final DeviceOrientationManager deviceOrientationListener; - private final boolean isFrontFacing; - private final int sensorOrientation; - private final String cameraName; - private final Size captureSize; - private final Size previewSize; private final boolean enableAudio; private final Context applicationContext; - private final CamcorderProfile recordingProfile; private final DartMessenger dartMessenger; - private final CameraZoom cameraZoom; - private final CameraCharacteristics cameraCharacteristics; + private final CameraProperties cameraProperties; + private final CameraFeatureFactory cameraFeatureFactory; + private final Activity activity; + /** A {@link CameraCaptureSession.CaptureCallback} that handles events related to JPEG capture. */ + private final CameraCaptureCallback cameraCaptureCallback; + /** A {@link Handler} for running tasks in the background. */ + private Handler backgroundHandler; + + /** An additional thread for running tasks that shouldn't block the UI. */ + private HandlerThread backgroundHandlerThread; private CameraDevice cameraDevice; - private CameraCaptureSession cameraCaptureSession; + private CameraCaptureSession captureSession; private ImageReader pictureImageReader; private ImageReader imageStreamReader; - private CaptureRequest.Builder captureRequestBuilder; + /** {@link CaptureRequest.Builder} for the camera preview */ + private CaptureRequest.Builder previewRequestBuilder; + private MediaRecorder mediaRecorder; + /** True when recording video. */ private boolean recordingVideo; - private File videoRecordingFile; - private FlashMode flashMode; - private ExposureMode exposureMode; - private FocusMode focusMode; - private PictureCaptureRequest pictureCaptureRequest; - private CameraRegions cameraRegions; - private int exposureOffset; - private boolean useAutoFocus = true; - private Range fpsRange; - private PlatformChannel.DeviceOrientation lockedCaptureOrientation; - private long preCaptureStartTime; - private static final HashMap supportedImageFormats; - // Current supported outputs - static { - supportedImageFormats = new HashMap<>(); - supportedImageFormats.put("yuv420", 35); - supportedImageFormats.put("jpeg", 256); - } + private File captureFile; + + /** Holds the current capture timeouts */ + private CaptureTimeoutsWrapper captureTimeouts; + + private MethodChannel.Result flutterResult; public Camera( final Activity activity, final SurfaceTextureEntry flutterTexture, + final CameraFeatureFactory cameraFeatureFactory, final DartMessenger dartMessenger, - final String cameraName, - final String resolutionPreset, - final boolean enableAudio) - throws CameraAccessException { + final CameraProperties cameraProperties, + final ResolutionPreset resolutionPreset, + final boolean enableAudio) { + if (activity == null) { throw new IllegalStateException("No activity available!"); } - this.cameraName = cameraName; + this.activity = activity; this.enableAudio = enableAudio; this.flutterTexture = flutterTexture; this.dartMessenger = dartMessenger; - this.cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); this.applicationContext = activity.getApplicationContext(); - this.flashMode = FlashMode.auto; - this.exposureMode = ExposureMode.auto; - this.focusMode = FocusMode.auto; - this.exposureOffset = 0; - - cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraName); - initFps(cameraCharacteristics); - sensorOrientation = cameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); - isFrontFacing = - cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) - == CameraMetadata.LENS_FACING_FRONT; - ResolutionPreset preset = ResolutionPreset.valueOf(resolutionPreset); - recordingProfile = - CameraUtils.getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); - captureSize = new Size(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); - previewSize = computeBestPreviewSize(cameraName, preset); - cameraZoom = - new CameraZoom( - cameraCharacteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE), - cameraCharacteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM)); - - deviceOrientationListener = - new DeviceOrientationManager(activity, dartMessenger, isFrontFacing, sensorOrientation); - deviceOrientationListener.start(); + this.cameraProperties = cameraProperties; + this.cameraFeatureFactory = cameraFeatureFactory; + this.cameraFeatures = + CameraFeatures.init( + cameraFeatureFactory, cameraProperties, activity, dartMessenger, resolutionPreset); + + // Create capture callback. + captureTimeouts = new CaptureTimeoutsWrapper(3000, 3000); + cameraCaptureCallback = CameraCaptureCallback.create(this, captureTimeouts); + + startBackgroundThread(); } - private void initFps(CameraCharacteristics cameraCharacteristics) { - try { - Range[] ranges = - cameraCharacteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES); - if (ranges != null) { - for (Range range : ranges) { - int upper = range.getUpper(); - Log.i("Camera", "[FPS Range Available] is:" + range); - if (upper >= 10) { - if (fpsRange == null || upper > fpsRange.getUpper()) { - fpsRange = range; - } - } - } - } - } catch (Exception e) { - e.printStackTrace(); + @Override + public void onConverged() { + takePictureAfterPrecapture(); + } + + @Override + public void onPrecapture() { + runPrecaptureSequence(); + } + + /** + * Updates the builder settings with all of the available features. + * + * @param requestBuilder request builder to update. + */ + private void updateBuilderSettings(CaptureRequest.Builder requestBuilder) { + for (CameraFeature feature : cameraFeatures.getAllFeatures()) { + Log.d(TAG, "Updating builder with feature: " + feature.getDebugName()); + feature.updateBuilder(requestBuilder); } - Log.i("Camera", "[FPS Range] is:" + fpsRange); } private void prepareMediaRecorder(String outputFilePath) throws IOException { + Log.i(TAG, "prepareMediaRecorder"); + if (mediaRecorder != null) { mediaRecorder.release(); } + final PlatformChannel.DeviceOrientation lockedOrientation = + ((SensorOrientationFeature) cameraFeatures.getSensorOrientation()) + .getLockedCaptureOrientation(); + mediaRecorder = - new MediaRecorderBuilder(recordingProfile, outputFilePath) + new MediaRecorderBuilder(getRecordingProfile(), outputFilePath) .setEnableAudio(enableAudio) .setMediaOrientation( - lockedCaptureOrientation == null - ? deviceOrientationListener.getMediaOrientation() - : deviceOrientationListener.getMediaOrientation(lockedCaptureOrientation)) + lockedOrientation == null + ? getDeviceOrientationManager().getVideoOrientation() + : getDeviceOrientationManager().getVideoOrientation(lockedOrientation)) .build(); } @SuppressLint("MissingPermission") public void open(String imageFormatGroup) throws CameraAccessException { + final ResolutionFeature resolutionFeature = cameraFeatures.getResolution(); + + if (!resolutionFeature.checkIsSupported()) { + // Tell the user that the camera they are trying to open is not supported, + // as its {@link android.media.CamcorderProfile} cannot be fetched due to the name + // not being a valid parsable integer. + dartMessenger.sendCameraErrorEvent( + "Camera with name \"" + + cameraProperties.getCameraName() + + "\" is not supported by this plugin."); + return; + } + + // Always capture using JPEG format. pictureImageReader = ImageReader.newInstance( - captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); + resolutionFeature.getCaptureSize().getWidth(), + resolutionFeature.getCaptureSize().getHeight(), + ImageFormat.JPEG, + 1); + // For image streaming, use the provided image format or fall back to YUV420. Integer imageFormat = supportedImageFormats.get(imageFormatGroup); if (imageFormat == null) { Log.w(TAG, "The selected imageFormatGroup is not supported by Android. Defaulting to yuv420"); imageFormat = ImageFormat.YUV_420_888; } - - // Used to steam image byte data to dart side. imageStreamReader = - ImageReader.newInstance(previewSize.getWidth(), previewSize.getHeight(), imageFormat, 2); + ImageReader.newInstance( + resolutionFeature.getPreviewSize().getWidth(), + resolutionFeature.getPreviewSize().getHeight(), + imageFormat, + 1); + // Open the camera. + CameraManager cameraManager = CameraUtils.getCameraManager(activity); cameraManager.openCamera( - cameraName, + cameraProperties.getCameraName(), new CameraDevice.StateCallback() { @Override public void onOpened(@NonNull CameraDevice device) { @@ -223,12 +253,12 @@ public void onOpened(@NonNull CameraDevice device) { try { startPreview(); dartMessenger.sendCameraInitializedEvent( - previewSize.getWidth(), - previewSize.getHeight(), - exposureMode, - focusMode, - isExposurePointSupported(), - isFocusPointSupported()); + resolutionFeature.getPreviewSize().getWidth(), + resolutionFeature.getPreviewSize().getHeight(), + cameraFeatures.getExposureLock().getValue(), + cameraFeatures.getAutoFocus().getValue(), + cameraFeatures.getExposurePoint().checkIsSupported(), + cameraFeatures.getFocusPoint().checkIsSupported()); } catch (CameraAccessException e) { dartMessenger.sendCameraErrorEvent(e.getMessage()); close(); @@ -237,18 +267,24 @@ public void onOpened(@NonNull CameraDevice device) { @Override public void onClosed(@NonNull CameraDevice camera) { + Log.i(TAG, "open | onClosed"); + dartMessenger.sendCameraClosingEvent(); super.onClosed(camera); } @Override public void onDisconnected(@NonNull CameraDevice cameraDevice) { + Log.i(TAG, "open | onDisconnected"); + close(); dartMessenger.sendCameraErrorEvent("The camera was disconnected."); } @Override public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { + Log.i(TAG, "open | onError"); + close(); String errorDescription; switch (errorCode) { @@ -273,7 +309,7 @@ public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { dartMessenger.sendCameraErrorEvent(errorDescription); } }, - null); + backgroundHandler); } private void createCaptureSession(int templateType, Surface... surfaces) @@ -288,39 +324,45 @@ private void createCaptureSession( closeCaptureSession(); // Create a new capture builder. - captureRequestBuilder = cameraDevice.createCaptureRequest(templateType); + previewRequestBuilder = cameraDevice.createCaptureRequest(templateType); - // Build Flutter surface to render to + // Build Flutter surface to render to. + ResolutionFeature resolutionFeature = cameraFeatures.getResolution(); SurfaceTexture surfaceTexture = flutterTexture.surfaceTexture(); - surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); + surfaceTexture.setDefaultBufferSize( + resolutionFeature.getPreviewSize().getWidth(), + resolutionFeature.getPreviewSize().getHeight()); Surface flutterSurface = new Surface(surfaceTexture); - captureRequestBuilder.addTarget(flutterSurface); + previewRequestBuilder.addTarget(flutterSurface); List remainingSurfaces = Arrays.asList(surfaces); if (templateType != CameraDevice.TEMPLATE_PREVIEW) { // If it is not preview mode, add all surfaces as targets. for (Surface surface : remainingSurfaces) { - captureRequestBuilder.addTarget(surface); + previewRequestBuilder.addTarget(surface); } } - cameraRegions = new CameraRegions(getRegionBoundaries()); + // Update camera regions. + Size cameraBoundaries = + CameraRegionUtils.getCameraBoundaries(cameraProperties, previewRequestBuilder); + cameraFeatures.getExposurePoint().setCameraBoundaries(cameraBoundaries); + cameraFeatures.getFocusPoint().setCameraBoundaries(cameraBoundaries); - // Prepare the callback + // Prepare the callback. CameraCaptureSession.StateCallback callback = new CameraCaptureSession.StateCallback() { @Override public void onConfigured(@NonNull CameraCaptureSession session) { + // Camera was already closed. if (cameraDevice == null) { dartMessenger.sendCameraErrorEvent("The camera was closed during configuration."); return; } - cameraCaptureSession = session; + captureSession = session; - updateFpsRange(); - updateFocus(focusMode); - updateFlash(flashMode); - updateExposure(exposureMode); + Log.i(TAG, "Updating builder settings"); + updateBuilderSettings(previewRequestBuilder); refreshPreviewCaptureSession( onSuccessCallback, (code, message) -> dartMessenger.sendCameraErrorEvent(message)); @@ -332,9 +374,9 @@ public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession } }; - // Start the session + // Start the session. if (VERSION.SDK_INT >= VERSION_CODES.P) { - // Collect all surfaces we want to render to. + // Collect all surfaces to render to. List configs = new ArrayList<>(); configs.add(new OutputConfiguration(flutterSurface)); for (Surface surface : remainingSurfaces) { @@ -342,7 +384,7 @@ public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession } createCaptureSessionWithSessionConfig(configs, callback); } else { - // Collect all surfaces we want to render to. + // Collect all surfaces to render to. List surfaceList = new ArrayList<>(); surfaceList.add(flutterSurface); surfaceList.addAll(remainingSurfaces); @@ -367,276 +409,273 @@ private void createCaptureSessionWithSessionConfig( private void createCaptureSession( List surfaces, CameraCaptureSession.StateCallback callback) throws CameraAccessException { - cameraDevice.createCaptureSession(surfaces, callback, null); + cameraDevice.createCaptureSession(surfaces, callback, backgroundHandler); } + // Send a repeating request to refresh capture session. private void refreshPreviewCaptureSession( @Nullable Runnable onSuccessCallback, @NonNull ErrorCallback onErrorCallback) { - if (cameraCaptureSession == null) { + if (captureSession == null) { + Log.i( + TAG, + "[refreshPreviewCaptureSession] captureSession not yet initialized, " + + "skipping preview capture session refresh."); return; } try { - cameraCaptureSession.setRepeatingRequest( - captureRequestBuilder.build(), - pictureCaptureCallback, - new Handler(Looper.getMainLooper())); + captureSession.setRepeatingRequest( + previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); if (onSuccessCallback != null) { onSuccessCallback.run(); } - } catch (CameraAccessException | IllegalStateException | IllegalArgumentException e) { - onErrorCallback.onError("cameraAccess", e.getMessage()); - } - } - private void writeToFile(ByteBuffer buffer, File file) throws IOException { - try (FileOutputStream outputStream = new FileOutputStream(file)) { - while (0 < buffer.remaining()) { - outputStream.getChannel().write(buffer); - } + } catch (CameraAccessException e) { + onErrorCallback.onError("cameraAccess", e.getMessage()); } } public void takePicture(@NonNull final Result result) { - // Only take 1 picture at a time - if (pictureCaptureRequest != null && !pictureCaptureRequest.isFinished()) { + // Only take one picture at a time. + if (cameraCaptureCallback.getCameraState() != CameraState.STATE_PREVIEW) { result.error("captureAlreadyActive", "Picture is currently already being captured", null); return; } - // Store the result - this.pictureCaptureRequest = new PictureCaptureRequest(result); - // Create temporary file + flutterResult = result; + + // Create temporary file. final File outputDir = applicationContext.getCacheDir(); - final File file; try { - file = File.createTempFile("CAP", ".jpg", outputDir); + captureFile = File.createTempFile("CAP", ".jpg", outputDir); + captureTimeouts.reset(); } catch (IOException | SecurityException e) { - pictureCaptureRequest.error("cannotCreateFile", e.getMessage(), null); + dartMessenger.error(flutterResult, "cannotCreateFile", e.getMessage(), null); return; } - // Listen for picture being taken - pictureImageReader.setOnImageAvailableListener( - reader -> { - try (Image image = reader.acquireLatestImage()) { - ByteBuffer buffer = image.getPlanes()[0].getBuffer(); - writeToFile(buffer, file); - pictureCaptureRequest.finish(file.getAbsolutePath()); - } catch (IOException e) { - pictureCaptureRequest.error("IOError", "Failed saving image", null); - } - }, - null); + // Listen for picture being taken. + pictureImageReader.setOnImageAvailableListener(this, backgroundHandler); - if (useAutoFocus) { + final AutoFocusFeature autoFocusFeature = cameraFeatures.getAutoFocus(); + final boolean isAutoFocusSupported = autoFocusFeature.checkIsSupported(); + if (isAutoFocusSupported && autoFocusFeature.getValue() == FocusMode.auto) { runPictureAutoFocus(); } else { - runPicturePreCapture(); + runPrecaptureSequence(); } } - private final CameraCaptureSession.CaptureCallback pictureCaptureCallback = - new CameraCaptureSession.CaptureCallback() { - @Override - public void onCaptureCompleted( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull TotalCaptureResult result) { - processCapture(result); - } + /** + * Run the precapture sequence for capturing a still image. This method should be called when a + * response is received in {@link #cameraCaptureCallback} from lockFocus(). + */ + private void runPrecaptureSequence() { + Log.i(TAG, "runPrecaptureSequence"); + try { + // First set precapture state to idle or else it can hang in STATE_WAITING_PRECAPTURE_START. + previewRequestBuilder.set( + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE); + captureSession.capture( + previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); + + // Repeating request to refresh preview session. + refreshPreviewCaptureSession( + null, + (code, message) -> dartMessenger.error(flutterResult, "cameraAccess", message, null)); - @Override - public void onCaptureProgressed( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull CaptureResult partialResult) { - processCapture(partialResult); - } + // Start precapture. + cameraCaptureCallback.setCameraState(CameraState.STATE_WAITING_PRECAPTURE_START); - @Override - public void onCaptureFailed( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull CaptureFailure failure) { - if (pictureCaptureRequest == null || pictureCaptureRequest.isFinished()) { - return; - } - String reason; - boolean fatalFailure = false; - switch (failure.getReason()) { - case CaptureFailure.REASON_ERROR: - reason = "An error happened in the framework"; - break; - case CaptureFailure.REASON_FLUSHED: - reason = "The capture has failed due to an abortCaptures() call"; - fatalFailure = true; - break; - default: - reason = "Unknown reason"; - } - Log.w("Camera", "pictureCaptureCallback.onCaptureFailed(): " + reason); - if (fatalFailure) pictureCaptureRequest.error("captureFailure", reason, null); - } - - private void processCapture(CaptureResult result) { - if (pictureCaptureRequest == null) { - return; - } + previewRequestBuilder.set( + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START); - Integer aeState = result.get(CaptureResult.CONTROL_AE_STATE); - Integer afState = result.get(CaptureResult.CONTROL_AF_STATE); - switch (pictureCaptureRequest.getState()) { - case focusing: - if (afState == null) { - return; - } else if (afState == CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED - || afState == CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED) { - // Some devices might return null here, in which case we will also continue. - if (aeState == null || aeState == CaptureResult.CONTROL_AE_STATE_CONVERGED) { - runPictureCapture(); - } else { - runPicturePreCapture(); - } - } - break; - case preCapture: - // Some devices might return null here, in which case we will also continue. - if (aeState == null - || aeState == CaptureRequest.CONTROL_AE_STATE_PRECAPTURE - || aeState == CaptureRequest.CONTROL_AE_STATE_FLASH_REQUIRED - || aeState == CaptureRequest.CONTROL_AE_STATE_CONVERGED) { - pictureCaptureRequest.setState(State.waitingPreCaptureReady); - setPreCaptureStartTime(); - } - break; - case waitingPreCaptureReady: - if (aeState == null || aeState != CaptureRequest.CONTROL_AE_STATE_PRECAPTURE) { - runPictureCapture(); - } else { - if (hitPreCaptureTimeout()) { - unlockAutoFocus(); - } - } - } - } - }; + // Trigger one capture to start AE sequence. + captureSession.capture( + previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); - private void runPictureAutoFocus() { - assert (pictureCaptureRequest != null); - - pictureCaptureRequest.setState(PictureCaptureRequest.State.focusing); - lockAutoFocus(pictureCaptureCallback); + } catch (CameraAccessException e) { + e.printStackTrace(); + } } - private void runPicturePreCapture() { - assert (pictureCaptureRequest != null); - pictureCaptureRequest.setState(PictureCaptureRequest.State.preCapture); + /** + * Capture a still picture. This method should be called when a response is received {@link + * #cameraCaptureCallback} from both lockFocus(). + */ + private void takePictureAfterPrecapture() { + Log.i(TAG, "captureStillPicture"); + cameraCaptureCallback.setCameraState(CameraState.STATE_CAPTURING); - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, - CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START); + if (cameraDevice == null) { + return; + } + // This is the CaptureRequest.Builder that is used to take a picture. + CaptureRequest.Builder stillBuilder; + try { + stillBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); + } catch (CameraAccessException e) { + dartMessenger.error(flutterResult, "cameraAccess", e.getMessage(), null); + return; + } + stillBuilder.addTarget(pictureImageReader.getSurface()); + + // Zoom. + stillBuilder.set( + CaptureRequest.SCALER_CROP_REGION, + previewRequestBuilder.get(CaptureRequest.SCALER_CROP_REGION)); + + // Have all features update the builder. + updateBuilderSettings(stillBuilder); + + // Orientation. + final PlatformChannel.DeviceOrientation lockedOrientation = + ((SensorOrientationFeature) cameraFeatures.getSensorOrientation()) + .getLockedCaptureOrientation(); + stillBuilder.set( + CaptureRequest.JPEG_ORIENTATION, + lockedOrientation == null + ? getDeviceOrientationManager().getPhotoOrientation() + : getDeviceOrientationManager().getPhotoOrientation(lockedOrientation)); + + CameraCaptureSession.CaptureCallback captureCallback = + new CameraCaptureSession.CaptureCallback() { + @Override + public void onCaptureCompleted( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull TotalCaptureResult result) { + unlockAutoFocus(); + } + }; - refreshPreviewCaptureSession( - () -> - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, - CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE), - (code, message) -> pictureCaptureRequest.error(code, message, null)); + try { + captureSession.stopRepeating(); + captureSession.abortCaptures(); + Log.i(TAG, "sending capture request"); + captureSession.capture(stillBuilder.build(), captureCallback, backgroundHandler); + } catch (CameraAccessException e) { + dartMessenger.error(flutterResult, "cameraAccess", e.getMessage(), null); + } + } + + @SuppressWarnings("deprecation") + private Display getDefaultDisplay() { + return activity.getWindowManager().getDefaultDisplay(); } - private void runPictureCapture() { - assert (pictureCaptureRequest != null); - pictureCaptureRequest.setState(PictureCaptureRequest.State.capturing); + /** Starts a background thread and its {@link Handler}. */ + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + public void startBackgroundThread() { + backgroundHandlerThread = new HandlerThread("CameraBackground"); try { - final CaptureRequest.Builder captureBuilder = - cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE); - captureBuilder.addTarget(pictureImageReader.getSurface()); - captureBuilder.set( - CaptureRequest.SCALER_CROP_REGION, - captureRequestBuilder.get(CaptureRequest.SCALER_CROP_REGION)); - captureBuilder.set( - CaptureRequest.JPEG_ORIENTATION, - lockedCaptureOrientation == null - ? deviceOrientationListener.getMediaOrientation() - : deviceOrientationListener.getMediaOrientation(lockedCaptureOrientation)); - - switch (flashMode) { - case off: - captureBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON); - captureBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF); - break; - case auto: - captureBuilder.set( - CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH); - break; - case always: - default: - captureBuilder.set( - CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH); - break; + backgroundHandlerThread.start(); + } catch (IllegalThreadStateException e) { + // Ignore exception in case the thread has already started. + } + backgroundHandler = new Handler(backgroundHandlerThread.getLooper()); + } + + /** Stops the background thread and its {@link Handler}. */ + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + public void stopBackgroundThread() { + if (backgroundHandlerThread != null) { + backgroundHandlerThread.quitSafely(); + try { + backgroundHandlerThread.join(); + } catch (InterruptedException e) { + dartMessenger.error(flutterResult, "cameraAccess", e.getMessage(), null); } - cameraCaptureSession.stopRepeating(); - cameraCaptureSession.capture( - captureBuilder.build(), - new CameraCaptureSession.CaptureCallback() { - @Override - public void onCaptureCompleted( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull TotalCaptureResult result) { - unlockAutoFocus(); - } - }, - null); - } catch (CameraAccessException e) { - pictureCaptureRequest.error("cameraAccess", e.getMessage(), null); } + backgroundHandlerThread = null; + backgroundHandler = null; + } + + /** Start capturing a picture, doing autofocus first. */ + private void runPictureAutoFocus() { + Log.i(TAG, "runPictureAutoFocus"); + + cameraCaptureCallback.setCameraState(CameraState.STATE_WAITING_FOCUS); + lockAutoFocus(); } - private void lockAutoFocus(CaptureCallback callback) { - captureRequestBuilder.set( + private void lockAutoFocus() { + Log.i(TAG, "lockAutoFocus"); + if (captureSession == null) { + Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); + return; + } + + // Trigger AF to start. + previewRequestBuilder.set( CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START); - refreshPreviewCaptureSession( - null, (code, message) -> pictureCaptureRequest.error(code, message, null)); + try { + captureSession.capture(previewRequestBuilder.build(), null, backgroundHandler); + } catch (CameraAccessException e) { + dartMessenger.sendCameraErrorEvent(e.getMessage()); + } } + /** Cancel and reset auto focus state and refresh the preview session. */ private void unlockAutoFocus() { - captureRequestBuilder.set( - CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL); - updateFocus(focusMode); + Log.i(TAG, "unlockAutoFocus"); + if (captureSession == null) { + Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); + return; + } try { - cameraCaptureSession.capture(captureRequestBuilder.build(), null, null); - } catch (CameraAccessException ignored) { + // Cancel existing AF state. + previewRequestBuilder.set( + CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL); + captureSession.capture(previewRequestBuilder.build(), null, backgroundHandler); + + // Set AF state to idle again. + previewRequestBuilder.set( + CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); + + captureSession.capture(previewRequestBuilder.build(), null, backgroundHandler); + } catch (CameraAccessException e) { + dartMessenger.sendCameraErrorEvent(e.getMessage()); + return; } - captureRequestBuilder.set( - CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_IDLE); refreshPreviewCaptureSession( null, - (errorCode, errorMessage) -> pictureCaptureRequest.error(errorCode, errorMessage, null)); + (errorCode, errorMessage) -> + dartMessenger.error(flutterResult, errorCode, errorMessage, null)); } - public void startVideoRecording(Result result) { + public void startVideoRecording(@NonNull Result result) { final File outputDir = applicationContext.getCacheDir(); try { - videoRecordingFile = File.createTempFile("REC", ".mp4", outputDir); + captureFile = File.createTempFile("REC", ".mp4", outputDir); } catch (IOException | SecurityException e) { result.error("cannotCreateFile", e.getMessage(), null); return; } - try { - prepareMediaRecorder(videoRecordingFile.getAbsolutePath()); - recordingVideo = true; + prepareMediaRecorder(captureFile.getAbsolutePath()); + } catch (IOException e) { + recordingVideo = false; + captureFile = null; + result.error("videoRecordingFailed", e.getMessage(), null); + return; + } + // Re-create autofocus feature so it's using video focus mode now. + cameraFeatures.setAutoFocus( + cameraFeatureFactory.createAutoFocusFeature(cameraProperties, true)); + recordingVideo = true; + try { createCaptureSession( CameraDevice.TEMPLATE_RECORD, () -> mediaRecorder.start(), mediaRecorder.getSurface()); result.success(null); - } catch (CameraAccessException | IOException e) { + } catch (CameraAccessException e) { recordingVideo = false; - videoRecordingFile = null; + captureFile = null; result.error("videoRecordingFailed", e.getMessage(), null); } } @@ -646,24 +685,25 @@ public void stopVideoRecording(@NonNull final Result result) { result.success(null); return; } - + // Re-create autofocus feature so it's using continuous capture focus mode now. + cameraFeatures.setAutoFocus( + cameraFeatureFactory.createAutoFocusFeature(cameraProperties, false)); + recordingVideo = false; + try { + captureSession.abortCaptures(); + mediaRecorder.stop(); + } catch (CameraAccessException | IllegalStateException e) { + // Ignore exceptions and try to continue (changes are camera session already aborted capture). + } + mediaRecorder.reset(); try { - recordingVideo = false; - - try { - cameraCaptureSession.abortCaptures(); - mediaRecorder.stop(); - } catch (CameraAccessException | IllegalStateException e) { - // Ignore exceptions and try to continue (changes are camera session already aborted capture) - } - - mediaRecorder.reset(); startPreview(); - result.success(videoRecordingFile.getAbsolutePath()); - videoRecordingFile = null; } catch (CameraAccessException | IllegalStateException e) { result.error("videoRecordingFailed", e.getMessage(), null); + return; } + result.success(captureFile.getAbsolutePath()); + captureFile = null; } public void pauseVideoRecording(@NonNull final Result result) { @@ -709,259 +749,185 @@ public void resumeVideoRecording(@NonNull final Result result) { result.success(null); } - public void setFlashMode(@NonNull final Result result, FlashMode mode) - throws CameraAccessException { - // Get the flash availability - Boolean flashAvailable = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.FLASH_INFO_AVAILABLE); - - // Check if flash is available. - if (flashAvailable == null || !flashAvailable) { - result.error("setFlashModeFailed", "Device does not have flash capabilities", null); - return; - } + /** + * Method handler for setting new flash modes. + * + * @param result Flutter result. + * @param newMode new mode. + */ + public void setFlashMode(@NonNull final Result result, @NonNull FlashMode newMode) { + // Save the new flash mode setting. + final FlashFeature flashFeature = cameraFeatures.getFlash(); + flashFeature.setValue(newMode); + flashFeature.updateBuilder(previewRequestBuilder); - // If switching directly from torch to auto or on, make sure we turn off the torch. - if (flashMode == FlashMode.torch && mode != FlashMode.torch && mode != FlashMode.off) { - updateFlash(FlashMode.off); - - this.cameraCaptureSession.setRepeatingRequest( - captureRequestBuilder.build(), - new CaptureCallback() { - private boolean isFinished = false; - - @Override - public void onCaptureCompleted( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull TotalCaptureResult captureResult) { - if (isFinished) { - return; - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> result.error("setFlashModeFailed", "Could not set flash mode.", null)); + } - updateFlash(mode); - refreshPreviewCaptureSession( - () -> { - result.success(null); - isFinished = true; - }, - (code, message) -> - result.error("setFlashModeFailed", "Could not set flash mode.", null)); - } + /** + * Method handler for setting new exposure modes. + * + * @param result Flutter result. + * @param newMode new mode. + */ + public void setExposureMode(@NonNull final Result result, @NonNull ExposureMode newMode) { + final ExposureLockFeature exposureLockFeature = cameraFeatures.getExposureLock(); + exposureLockFeature.setValue(newMode); + exposureLockFeature.updateBuilder(previewRequestBuilder); - @Override - public void onCaptureFailed( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull CaptureFailure failure) { - if (isFinished) { - return; - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> + result.error("setExposureModeFailed", "Could not set exposure mode.", null)); + } - result.error("setFlashModeFailed", "Could not set flash mode.", null); - isFinished = true; - } - }, - null); - } else { - updateFlash(mode); + /** + * Sets new exposure point from dart. + * + * @param result Flutter result. + * @param point The exposure point. + */ + public void setExposurePoint(@NonNull final Result result, @Nullable Point point) { + final ExposurePointFeature exposurePointFeature = cameraFeatures.getExposurePoint(); + exposurePointFeature.setValue(point); + exposurePointFeature.updateBuilder(previewRequestBuilder); - refreshPreviewCaptureSession( - () -> result.success(null), - (code, message) -> result.error("setFlashModeFailed", "Could not set flash mode.", null)); - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> + result.error("setExposurePointFailed", "Could not set exposure point.", null)); } - public void setExposureMode(@NonNull final Result result, ExposureMode mode) - throws CameraAccessException { - updateExposure(mode); - cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - result.success(null); + /** Return the max exposure offset value supported by the camera to dart. */ + public double getMaxExposureOffset() { + return cameraFeatures.getExposureOffset().getMaxExposureOffset(); } - public void setExposurePoint(@NonNull final Result result, Double x, Double y) - throws CameraAccessException { - // Check if exposure point functionality is available. - if (!isExposurePointSupported()) { - result.error( - "setExposurePointFailed", "Device does not have exposure point capabilities", null); - return; - } - // Check if the current region boundaries are known - if (cameraRegions.getMaxBoundaries() == null) { - result.error("setExposurePointFailed", "Could not determine max region boundaries", null); - return; - } - // Set the metering rectangle - if (x == null || y == null) cameraRegions.resetAutoExposureMeteringRectangle(); - else cameraRegions.setAutoExposureMeteringRectangleFromPoint(y, 1 - x); - // Apply it - updateExposure(exposureMode); - refreshPreviewCaptureSession( - () -> result.success(null), (code, message) -> result.error("CameraAccess", message, null)); + /** Return the min exposure offset value supported by the camera to dart. */ + public double getMinExposureOffset() { + return cameraFeatures.getExposureOffset().getMinExposureOffset(); } - public void setFocusMode(@NonNull final Result result, FocusMode mode) - throws CameraAccessException { - this.focusMode = mode; - - updateFocus(mode); + /** Return the exposure offset step size to dart. */ + public double getExposureOffsetStepSize() { + return cameraFeatures.getExposureOffset().getExposureOffsetStepSize(); + } - switch (mode) { - case auto: - refreshPreviewCaptureSession( - null, (code, message) -> result.error("setFocusMode", message, null)); - break; + /** + * Sets new focus mode from dart. + * + * @param result Flutter result. + * @param newMode New mode. + */ + public void setFocusMode(final Result result, @NonNull FocusMode newMode) { + final AutoFocusFeature autoFocusFeature = cameraFeatures.getAutoFocus(); + autoFocusFeature.setValue(newMode); + autoFocusFeature.updateBuilder(previewRequestBuilder); + + /* + * For focus mode an extra step of actually locking/unlocking the + * focus has to be done, in order to ensure it goes into the correct state. + */ + switch (newMode) { case locked: - lockAutoFocus( - new CaptureCallback() { - @Override - public void onCaptureCompleted( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull TotalCaptureResult result) { - unlockAutoFocus(); - } - }); - break; - } - result.success(null); - } + // Perform a single focus trigger. + lockAutoFocus(); + if (captureSession == null) { + Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); + return; + } - public void setFocusPoint(@NonNull final Result result, Double x, Double y) - throws CameraAccessException { - // Check if focus point functionality is available. - if (!isFocusPointSupported()) { - result.error("setFocusPointFailed", "Device does not have focus point capabilities", null); - return; - } + // Set AF state to idle again. + previewRequestBuilder.set( + CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); - // Check if the current region boundaries are known - if (cameraRegions.getMaxBoundaries() == null) { - result.error("setFocusPointFailed", "Could not determine max region boundaries", null); - return; + try { + captureSession.setRepeatingRequest( + previewRequestBuilder.build(), null, backgroundHandler); + } catch (CameraAccessException e) { + if (result != null) { + result.error("setFocusModeFailed", "Error setting focus mode: " + e.getMessage(), null); + } + return; + } + break; + case auto: + // Cancel current AF trigger and set AF to idle again. + unlockAutoFocus(); + break; } - // Set the metering rectangle - if (x == null || y == null) { - cameraRegions.resetAutoFocusMeteringRectangle(); - } else { - cameraRegions.setAutoFocusMeteringRectangleFromPoint(y, 1 - x); + if (result != null) { + result.success(null); } - - // Apply the new metering rectangle - setFocusMode(result, focusMode); } - @TargetApi(VERSION_CODES.P) - private boolean supportsDistortionCorrection() throws CameraAccessException { - int[] availableDistortionCorrectionModes = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.DISTORTION_CORRECTION_AVAILABLE_MODES); - if (availableDistortionCorrectionModes == null) availableDistortionCorrectionModes = new int[0]; - long nonOffModesSupported = - Arrays.stream(availableDistortionCorrectionModes) - .filter((value) -> value != CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) - .count(); - return nonOffModesSupported > 0; - } - - private Size getRegionBoundaries() throws CameraAccessException { - // No distortion correction support - if (android.os.Build.VERSION.SDK_INT < VERSION_CODES.P || !supportsDistortionCorrection()) { - return cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE); - } - // Get the current distortion correction mode - Integer distortionCorrectionMode = - captureRequestBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE); - // Return the correct boundaries depending on the mode - android.graphics.Rect rect; - if (distortionCorrectionMode == null - || distortionCorrectionMode == CaptureRequest.DISTORTION_CORRECTION_MODE_OFF) { - rect = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.SENSOR_INFO_PRE_CORRECTION_ACTIVE_ARRAY_SIZE); - } else { - rect = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); - } - return rect == null ? null : new Size(rect.width(), rect.height()); - } + /** + * Sets new focus point from dart. + * + * @param result Flutter result. + * @param point the new coordinates. + */ + public void setFocusPoint(@NonNull final Result result, @Nullable Point point) { + final FocusPointFeature focusPointFeature = cameraFeatures.getFocusPoint(); + focusPointFeature.setValue(point); + focusPointFeature.updateBuilder(previewRequestBuilder); - private boolean isExposurePointSupported() throws CameraAccessException { - Integer supportedRegions = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_MAX_REGIONS_AE); - return supportedRegions != null && supportedRegions > 0; - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> result.error("setFocusPointFailed", "Could not set focus point.", null)); - private boolean isFocusPointSupported() throws CameraAccessException { - Integer supportedRegions = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_MAX_REGIONS_AF); - return supportedRegions != null && supportedRegions > 0; + this.setFocusMode(null, cameraFeatures.getAutoFocus().getValue()); } - public double getMinExposureOffset() throws CameraAccessException { - Range range = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE); - double minStepped = range == null ? 0 : range.getLower(); - double stepSize = getExposureOffsetStepSize(); - return minStepped * stepSize; - } + /** + * Sets a new exposure offset from dart. From dart the offset comes as a double, like +1.3 or + * -1.3. + * + * @param result flutter result. + * @param offset new value. + */ + public void setExposureOffset(@NonNull final Result result, double offset) { + final ExposureOffsetFeature exposureOffsetFeature = cameraFeatures.getExposureOffset(); + exposureOffsetFeature.setValue(offset); + exposureOffsetFeature.updateBuilder(previewRequestBuilder); - public double getMaxExposureOffset() throws CameraAccessException { - Range range = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE); - double maxStepped = range == null ? 0 : range.getUpper(); - double stepSize = getExposureOffsetStepSize(); - return maxStepped * stepSize; + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> + result.error("setExposureOffsetFailed", "Could not set exposure offset.", null)); } - public double getExposureOffsetStepSize() throws CameraAccessException { - Rational stepSize = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP); - return stepSize == null ? 0.0 : stepSize.doubleValue(); + public float getMaxZoomLevel() { + return cameraFeatures.getZoomLevel().getMaximumZoomLevel(); } - public void setExposureOffset(@NonNull final Result result, double offset) - throws CameraAccessException { - // Set the exposure offset - double stepSize = getExposureOffsetStepSize(); - exposureOffset = (int) (offset / stepSize); - // Apply it - updateExposure(exposureMode); - this.cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - result.success(offset); + public float getMinZoomLevel() { + return cameraFeatures.getZoomLevel().getMinimumZoomLevel(); } - public float getMaxZoomLevel() { - return cameraZoom.maxZoom; + /** Shortcut to get current recording profile. */ + CamcorderProfile getRecordingProfile() { + return cameraFeatures.getResolution().getRecordingProfile(); } - public float getMinZoomLevel() { - return CameraZoom.DEFAULT_ZOOM_FACTOR; + /** Shortut to get deviceOrientationListener. */ + DeviceOrientationManager getDeviceOrientationManager() { + return cameraFeatures.getSensorOrientation().getDeviceOrientationManager(); } + /** + * Sets zoom level from dart. + * + * @param result Flutter result. + * @param zoom new value. + */ public void setZoomLevel(@NonNull final Result result, float zoom) throws CameraAccessException { - float maxZoom = cameraZoom.maxZoom; - float minZoom = CameraZoom.DEFAULT_ZOOM_FACTOR; + final ZoomLevelFeature zoomLevel = cameraFeatures.getZoomLevel(); + float maxZoom = zoomLevel.getMaximumZoomLevel(); + float minZoom = zoomLevel.getMinimumZoomLevel(); if (zoom > maxZoom || zoom < minZoom) { String errorMessage = @@ -974,122 +940,31 @@ public void setZoomLevel(@NonNull final Result result, float zoom) throws Camera return; } - //Zoom area is calculated relative to sensor area (activeRect) - if (captureRequestBuilder != null) { - final Rect computedZoom = cameraZoom.computeZoom(zoom); - captureRequestBuilder.set(CaptureRequest.SCALER_CROP_REGION, computedZoom); - cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - } + zoomLevel.setValue(zoom); + zoomLevel.updateBuilder(previewRequestBuilder); - result.success(null); + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> result.error("setZoomLevelFailed", "Could not set zoom level.", null)); } + /** + * Lock capture orientation from dart. + * + * @param orientation new orientation. + */ public void lockCaptureOrientation(PlatformChannel.DeviceOrientation orientation) { - this.lockedCaptureOrientation = orientation; + cameraFeatures.getSensorOrientation().lockCaptureOrientation(orientation); } + /** Unlock capture orientation from dart. */ public void unlockCaptureOrientation() { - this.lockedCaptureOrientation = null; - } - - private void updateFpsRange() { - if (fpsRange == null) { - return; - } - - captureRequestBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, fpsRange); - } - - private void updateFocus(FocusMode mode) { - if (useAutoFocus) { - int[] modes = cameraCharacteristics.get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES); - // Auto focus is not supported - if (modes == null - || modes.length == 0 - || (modes.length == 1 && modes[0] == CameraCharacteristics.CONTROL_AF_MODE_OFF)) { - useAutoFocus = false; - captureRequestBuilder.set( - CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF); - } else { - // Applying auto focus - switch (mode) { - case locked: - captureRequestBuilder.set( - CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO); - break; - case auto: - captureRequestBuilder.set( - CaptureRequest.CONTROL_AF_MODE, - recordingVideo - ? CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO - : CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE); - default: - break; - } - MeteringRectangle afRect = cameraRegions.getAFMeteringRectangle(); - captureRequestBuilder.set( - CaptureRequest.CONTROL_AF_REGIONS, - afRect == null ? null : new MeteringRectangle[] {afRect}); - } - } else { - captureRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF); - } - } - - private void updateExposure(ExposureMode mode) { - exposureMode = mode; - - // Applying auto exposure - MeteringRectangle aeRect = cameraRegions.getAEMeteringRectangle(); - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_REGIONS, - aeRect == null ? null : new MeteringRectangle[] {cameraRegions.getAEMeteringRectangle()}); - - switch (mode) { - case locked: - captureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, true); - break; - case auto: - default: - captureRequestBuilder.set(CaptureRequest.CONTROL_AE_LOCK, false); - break; - } - - captureRequestBuilder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureOffset); - } - - private void updateFlash(FlashMode mode) { - // Get flash - flashMode = mode; - - // Applying flash modes - switch (flashMode) { - case off: - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON); - captureRequestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF); - break; - case auto: - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH); - captureRequestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF); - break; - case always: - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH); - captureRequestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF); - break; - case torch: - default: - captureRequestBuilder.set( - CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON); - captureRequestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH); - break; - } + cameraFeatures.getSensorOrientation().unlockCaptureOrientation(); } public void startPreview() throws CameraAccessException { if (pictureImageReader == null || pictureImageReader.getSurface() == null) return; + Log.i(TAG, "startPreview"); createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); } @@ -1097,6 +972,7 @@ public void startPreview() throws CameraAccessException { public void startPreviewWithImageStream(EventChannel imageStreamChannel) throws CameraAccessException { createCaptureSession(CameraDevice.TEMPLATE_RECORD, imageStreamReader.getSurface()); + Log.i(TAG, "startPreviewWithImageStream"); imageStreamChannel.setStreamHandler( new EventChannel.StreamHandler() { @@ -1107,15 +983,43 @@ public void onListen(Object o, EventChannel.EventSink imageStreamSink) { @Override public void onCancel(Object o) { - imageStreamReader.setOnImageAvailableListener(null, null); + imageStreamReader.setOnImageAvailableListener(null, backgroundHandler); } }); } + /** + * This a callback object for the {@link ImageReader}. "onImageAvailable" will be called when a + * still image is ready to be saved. + */ + @Override + public void onImageAvailable(ImageReader reader) { + Log.i(TAG, "onImageAvailable"); + + backgroundHandler.post( + new ImageSaver( + // Use acquireNextImage since image reader is only for one image. + reader.acquireNextImage(), + captureFile, + new ImageSaver.Callback() { + @Override + public void onComplete(String absolutePath) { + dartMessenger.finish(flutterResult, absolutePath); + } + + @Override + public void onError(String errorCode, String errorMessage) { + dartMessenger.error(flutterResult, errorCode, errorMessage, null); + } + })); + cameraCaptureCallback.setCameraState(CameraState.STATE_PREVIEW); + } + private void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) { imageStreamReader.setOnImageAvailableListener( reader -> { - Image img = reader.acquireLatestImage(); + // Use acquireNextImage since image reader is only for one image. + Image img = reader.acquireNextImage(); if (img == null) return; List> planes = new ArrayList<>(); @@ -1139,41 +1043,24 @@ private void setImageStreamImageAvailableListener(final EventChannel.EventSink i imageBuffer.put("format", img.getFormat()); imageBuffer.put("planes", planes); - imageStreamSink.success(imageBuffer); + final Handler handler = new Handler(Looper.getMainLooper()); + handler.post(() -> imageStreamSink.success(imageBuffer)); img.close(); }, - null); - } - - public void stopImageStream() throws CameraAccessException { - if (imageStreamReader != null) { - imageStreamReader.setOnImageAvailableListener(null, null); - } - startPreview(); - } - - /** Sets the time the pre-capture sequence started. */ - private void setPreCaptureStartTime() { - preCaptureStartTime = SystemClock.elapsedRealtime(); - } - - /** - * Check if the timeout for the pre-capture sequence has been reached. - * - * @return true if the timeout is reached; otherwise false is returned. - */ - private boolean hitPreCaptureTimeout() { - return (SystemClock.elapsedRealtime() - preCaptureStartTime) > PRECAPTURE_TIMEOUT_MS; + backgroundHandler); } private void closeCaptureSession() { - if (cameraCaptureSession != null) { - cameraCaptureSession.close(); - cameraCaptureSession = null; + if (captureSession != null) { + Log.i(TAG, "closeCaptureSession"); + + captureSession.close(); + captureSession = null; } } public void close() { + Log.i(TAG, "close"); closeCaptureSession(); if (cameraDevice != null) { @@ -1193,11 +1080,15 @@ public void close() { mediaRecorder.release(); mediaRecorder = null; } + + stopBackgroundThread(); } public void dispose() { + Log.i(TAG, "dispose"); + close(); flutterTexture.release(); - deviceOrientationListener.stop(); + getDeviceOrientationManager().stop(); } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java index 75730ab41711..ef3a2b9b5d83 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java @@ -8,9 +8,11 @@ import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugins.camera.CameraPermissions.PermissionsRegistry; import io.flutter.view.TextureRegistry; @@ -51,7 +53,8 @@ public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registra registrar.activity(), registrar.messenger(), registrar::addRequestPermissionsResultListener, - registrar.view()); + registrar.view(), + null); } @Override @@ -70,18 +73,17 @@ public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { binding.getActivity(), flutterPluginBinding.getBinaryMessenger(), binding::addRequestPermissionsResultListener, - flutterPluginBinding.getTextureRegistry()); + flutterPluginBinding.getTextureRegistry(), + FlutterLifecycleAdapter.getActivityLifecycle(binding)); } @Override public void onDetachedFromActivity() { - if (methodCallHandler == null) { - // Could be on too low of an SDK to have started listening originally. - return; + // Could be on too low of an SDK to have started listening originally. + if (methodCallHandler != null) { + methodCallHandler.stopListening(); + methodCallHandler = null; } - - methodCallHandler.stopListening(); - methodCallHandler = null; } @Override @@ -98,7 +100,8 @@ private void maybeStartListening( Activity activity, BinaryMessenger messenger, PermissionsRegistry permissionsRegistry, - TextureRegistry textureRegistry) { + TextureRegistry textureRegistry, + @Nullable Lifecycle lifecycle) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { // If the sdk is less than 21 (min sdk for Camera2) we don't register the plugin. return; @@ -106,6 +109,11 @@ private void maybeStartListening( methodCallHandler = new MethodCallHandlerImpl( - activity, messenger, new CameraPermissions(), permissionsRegistry, textureRegistry); + activity, + messenger, + new CameraPermissions(), + permissionsRegistry, + textureRegistry, + lifecycle); } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java index ff8a49f1d148..951a2797d68f 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java @@ -11,6 +11,7 @@ import android.util.Size; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; import java.util.Arrays; /** @@ -69,11 +70,32 @@ && supportsDistortionCorrection(cameraProperties)) { * boundaries. */ public static MeteringRectangle convertPointToMeteringRectangle( - @NonNull Size boundaries, double x, double y) { + @NonNull Size boundaries, + double x, + double y, + @NonNull PlatformChannel.DeviceOrientation orientation) { assert (boundaries.getWidth() > 0 && boundaries.getHeight() > 0); assert (x >= 0 && x <= 1); assert (y >= 0 && y <= 1); - + // Rotate the coordinates to match the device orientation. + double oldX = x, oldY = y; + switch (orientation) { + case PORTRAIT_UP: // 90 ccw. + y = 1 - oldX; + x = oldY; + break; + case PORTRAIT_DOWN: // 90 cw. + x = 1 - oldY; + y = oldX; + break; + case LANDSCAPE_LEFT: + // No rotation required. + break; + case LANDSCAPE_RIGHT: // 180. + x = 1 - x; + y = 1 - y; + break; + } // Interpolate the target coordinate. int targetX = (int) Math.round(x * ((double) (boundaries.getWidth() - 1))); int targetY = (int) Math.round(y * ((double) (boundaries.getHeight() - 1))); @@ -98,7 +120,6 @@ public static MeteringRectangle convertPointToMeteringRectangle( if (targetY > maxTargetY) { targetY = maxTargetY; } - // Build the metering rectangle. return MeteringRectangleFactory.create(targetX, targetY, targetWidth, targetHeight, 1); } @@ -130,7 +151,7 @@ static class MeteringRectangleFactory { * @param width width >= 0. * @param height height >= 0. * @param meteringWeight weight between {@value MeteringRectangle#METERING_WEIGHT_MIN} and - * {@value MeteringRectangle#METERING_WEIGHT_MAX} inclusively + * {@value MeteringRectangle#METERING_WEIGHT_MAX} inclusively. * @return new instance of the {@link MeteringRectangle} class. * @throws IllegalArgumentException if any of the parameters were negative. */ diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java deleted file mode 100644 index 60c866cd82d5..000000000000 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegions.java +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera; - -import android.hardware.camera2.params.MeteringRectangle; -import android.util.Size; - -public final class CameraRegions { - private MeteringRectangle aeMeteringRectangle; - private MeteringRectangle afMeteringRectangle; - private Size maxBoundaries; - - public CameraRegions(Size maxBoundaries) { - assert (maxBoundaries == null || maxBoundaries.getWidth() > 0); - assert (maxBoundaries == null || maxBoundaries.getHeight() > 0); - this.maxBoundaries = maxBoundaries; - } - - public MeteringRectangle getAEMeteringRectangle() { - return aeMeteringRectangle; - } - - public MeteringRectangle getAFMeteringRectangle() { - return afMeteringRectangle; - } - - public Size getMaxBoundaries() { - return this.maxBoundaries; - } - - public void resetAutoExposureMeteringRectangle() { - this.aeMeteringRectangle = null; - } - - public void setAutoExposureMeteringRectangleFromPoint(double x, double y) { - this.aeMeteringRectangle = getMeteringRectangleForPoint(maxBoundaries, x, y); - } - - public void resetAutoFocusMeteringRectangle() { - this.afMeteringRectangle = null; - } - - public void setAutoFocusMeteringRectangleFromPoint(double x, double y) { - this.afMeteringRectangle = getMeteringRectangleForPoint(maxBoundaries, x, y); - } - - public MeteringRectangle getMeteringRectangleForPoint(Size maxBoundaries, double x, double y) { - assert (x >= 0 && x <= 1); - assert (y >= 0 && y <= 1); - if (maxBoundaries == null) - throw new IllegalStateException( - "Functionality for managing metering rectangles is unavailable as this CameraRegions instance was initialized with null boundaries."); - - // Interpolate the target coordinate - int targetX = (int) Math.round(x * ((double) (maxBoundaries.getWidth() - 1))); - int targetY = (int) Math.round(y * ((double) (maxBoundaries.getHeight() - 1))); - // Determine the dimensions of the metering triangle (10th of the viewport) - int targetWidth = (int) Math.round(((double) maxBoundaries.getWidth()) / 10d); - int targetHeight = (int) Math.round(((double) maxBoundaries.getHeight()) / 10d); - // Adjust target coordinate to represent top-left corner of metering rectangle - targetX -= targetWidth / 2; - targetY -= targetHeight / 2; - // Adjust target coordinate as to not fall out of bounds - if (targetX < 0) targetX = 0; - if (targetY < 0) targetY = 0; - int maxTargetX = maxBoundaries.getWidth() - 1 - targetWidth; - int maxTargetY = maxBoundaries.getHeight() - 1 - targetHeight; - if (targetX > maxTargetX) targetX = maxTargetX; - if (targetY > maxTargetY) targetY = maxTargetY; - - // Build the metering rectangle - return new MeteringRectangle(targetX, targetY, targetWidth, targetHeight, 1); - } -} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java index b4d4689f2b4e..003d80a6c241 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java @@ -6,20 +6,12 @@ import android.app.Activity; import android.content.Context; -import android.graphics.ImageFormat; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraManager; import android.hardware.camera2.CameraMetadata; -import android.hardware.camera2.params.StreamConfigurationMap; -import android.media.CamcorderProfile; -import android.util.Size; import io.flutter.embedding.engine.systemchannels.PlatformChannel; -import io.flutter.plugins.camera.types.ResolutionPreset; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,23 +21,24 @@ public final class CameraUtils { private CameraUtils() {} - static PlatformChannel.DeviceOrientation getDeviceOrientationFromDegrees(int degrees) { - // Round to the nearest 90 degrees. - degrees = (int) (Math.round(degrees / 90.0) * 90) % 360; - // Determine the corresponding device orientation. - switch (degrees) { - case 90: - return PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT; - case 180: - return PlatformChannel.DeviceOrientation.PORTRAIT_DOWN; - case 270: - return PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT; - case 0: - default: - return PlatformChannel.DeviceOrientation.PORTRAIT_UP; - } + /** + * Gets the {@link CameraManager} singleton. + * + * @param context The context to get the {@link CameraManager} singleton from. + * @return The {@link CameraManager} singleton. + */ + static CameraManager getCameraManager(Context context) { + return (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); } + /** + * Serializes the {@link PlatformChannel.DeviceOrientation} to a string value. + * + * @param orientation The orientation to serialize. + * @return The serialized orientation. + * @throws UnsupportedOperationException when the provided orientation not have a corresponding + * string value. + */ static String serializeDeviceOrientation(PlatformChannel.DeviceOrientation orientation) { if (orientation == null) throw new UnsupportedOperationException("Could not serialize null device orientation."); @@ -64,6 +57,15 @@ static String serializeDeviceOrientation(PlatformChannel.DeviceOrientation orien } } + /** + * Deserializes a string value to its corresponding {@link PlatformChannel.DeviceOrientation} + * value. + * + * @param orientation The string value to deserialize. + * @return The deserialized orientation. + * @throws UnsupportedOperationException when the provided string value does not have a + * corresponding {@link PlatformChannel.DeviceOrientation}. + */ static PlatformChannel.DeviceOrientation deserializeDeviceOrientation(String orientation) { if (orientation == null) throw new UnsupportedOperationException("Could not deserialize null device orientation."); @@ -82,23 +84,13 @@ static PlatformChannel.DeviceOrientation deserializeDeviceOrientation(String ori } } - static Size computeBestPreviewSize(String cameraName, ResolutionPreset preset) { - if (preset.ordinal() > ResolutionPreset.high.ordinal()) { - preset = ResolutionPreset.high; - } - - CamcorderProfile profile = - getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); - return new Size(profile.videoFrameWidth, profile.videoFrameHeight); - } - - static Size computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { - // For still image captures, we use the largest available size. - return Collections.max( - Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), - new CompareSizesByArea()); - } - + /** + * Gets all the available cameras for the device. + * + * @param activity The current Android activity. + * @return A map of all the available cameras, with their name as their key. + * @throws CameraAccessException when the camera could not be accessed. + */ public static List> getAvailableCameras(Activity activity) throws CameraAccessException { CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); @@ -127,52 +119,4 @@ public static List> getAvailableCameras(Activity activity) } return cameras; } - - static CamcorderProfile getBestAvailableCamcorderProfileForResolutionPreset( - String cameraName, ResolutionPreset preset) { - int cameraId = Integer.parseInt(cameraName); - switch (preset) { - // All of these cases deliberately fall through to get the best available profile. - case max: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_HIGH)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_HIGH); - } - case ultraHigh: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_2160P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_2160P); - } - case veryHigh: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_1080P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_1080P); - } - case high: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_720P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_720P); - } - case medium: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_480P)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_480P); - } - case low: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_QVGA)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_QVGA); - } - default: - if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_LOW)) { - return CamcorderProfile.get(cameraId, CamcorderProfile.QUALITY_LOW); - } else { - throw new IllegalArgumentException( - "No capture session available for current capture session."); - } - } - } - - private static class CompareSizesByArea implements Comparator { - @Override - public int compare(Size lhs, Size rhs) { - // We cast here to ensure the multiplications won't overflow. - return Long.signum( - (long) lhs.getWidth() * lhs.getHeight() - (long) rhs.getWidth() * rhs.getHeight()); - } - } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java index 93b963e65821..dc62fce524d3 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java @@ -11,8 +11,8 @@ import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugins.camera.types.ExposureMode; -import io.flutter.plugins.camera.types.FocusMode; +import io.flutter.plugins.camera.features.autofocus.FocusMode; +import io.flutter.plugins.camera.features.exposurelock.ExposureMode; import java.util.HashMap; import java.util.Map; @@ -178,4 +178,28 @@ public void run() { } }); } + + /** + * Send a success payload to a {@link MethodChannel.Result} on the main thread. + * + * @param payload The payload to send. + */ + public void finish(MethodChannel.Result result, Object payload) { + handler.post(() -> result.success(payload)); + } + + /** + * Send an error payload to a {@link MethodChannel.Result} on the main thread. + * + * @param errorCode error code. + * @param errorMessage error message. + * @param errorDetails error details. + */ + public void error( + MethodChannel.Result result, + String errorCode, + @Nullable String errorMessage, + @Nullable Object errorDetails) { + handler.post(() -> result.error(errorCode, errorMessage, errorDetails)); + } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DeviceOrientationManager.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DeviceOrientationManager.java deleted file mode 100644 index 634596dde8bb..000000000000 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DeviceOrientationManager.java +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera; - -import android.app.Activity; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.res.Configuration; -import android.hardware.SensorManager; -import android.provider.Settings; -import android.view.Display; -import android.view.OrientationEventListener; -import android.view.Surface; -import android.view.WindowManager; -import io.flutter.embedding.engine.systemchannels.PlatformChannel; - -class DeviceOrientationManager { - - private static final IntentFilter orientationIntentFilter = - new IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED); - - private final Activity activity; - private final DartMessenger messenger; - private final boolean isFrontFacing; - private final int sensorOrientation; - private PlatformChannel.DeviceOrientation lastOrientation; - private OrientationEventListener orientationEventListener; - private BroadcastReceiver broadcastReceiver; - - public DeviceOrientationManager( - Activity activity, DartMessenger messenger, boolean isFrontFacing, int sensorOrientation) { - this.activity = activity; - this.messenger = messenger; - this.isFrontFacing = isFrontFacing; - this.sensorOrientation = sensorOrientation; - } - - public void start() { - startSensorListener(); - startUIListener(); - } - - public void stop() { - stopSensorListener(); - stopUIListener(); - } - - public int getMediaOrientation() { - return this.getMediaOrientation(this.lastOrientation); - } - - public int getMediaOrientation(PlatformChannel.DeviceOrientation orientation) { - int angle = 0; - - // Fallback to device orientation when the orientation value is null - if (orientation == null) { - orientation = getUIOrientation(); - } - - switch (orientation) { - case PORTRAIT_UP: - angle = 0; - break; - case PORTRAIT_DOWN: - angle = 180; - break; - case LANDSCAPE_LEFT: - angle = 90; - break; - case LANDSCAPE_RIGHT: - angle = 270; - break; - } - if (isFrontFacing) angle *= -1; - return (angle + sensorOrientation + 360) % 360; - } - - private void startSensorListener() { - if (orientationEventListener != null) return; - orientationEventListener = - new OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) { - @Override - public void onOrientationChanged(int angle) { - if (!isSystemAutoRotationLocked()) { - PlatformChannel.DeviceOrientation newOrientation = calculateSensorOrientation(angle); - if (!newOrientation.equals(lastOrientation)) { - lastOrientation = newOrientation; - messenger.sendDeviceOrientationChangeEvent(newOrientation); - } - } - } - }; - if (orientationEventListener.canDetectOrientation()) { - orientationEventListener.enable(); - } - } - - private void startUIListener() { - if (broadcastReceiver != null) return; - broadcastReceiver = - new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (isSystemAutoRotationLocked()) { - PlatformChannel.DeviceOrientation orientation = getUIOrientation(); - if (!orientation.equals(lastOrientation)) { - lastOrientation = orientation; - messenger.sendDeviceOrientationChangeEvent(orientation); - } - } - } - }; - activity.registerReceiver(broadcastReceiver, orientationIntentFilter); - broadcastReceiver.onReceive(activity, null); - } - - private void stopSensorListener() { - if (orientationEventListener == null) return; - orientationEventListener.disable(); - orientationEventListener = null; - } - - private void stopUIListener() { - if (broadcastReceiver == null) return; - activity.unregisterReceiver(broadcastReceiver); - broadcastReceiver = null; - } - - private boolean isSystemAutoRotationLocked() { - return android.provider.Settings.System.getInt( - activity.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) - != 1; - } - - private PlatformChannel.DeviceOrientation getUIOrientation() { - final int rotation = getDisplay().getRotation(); - final int orientation = activity.getResources().getConfiguration().orientation; - - switch (orientation) { - case Configuration.ORIENTATION_PORTRAIT: - if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) { - return PlatformChannel.DeviceOrientation.PORTRAIT_UP; - } else { - return PlatformChannel.DeviceOrientation.PORTRAIT_DOWN; - } - case Configuration.ORIENTATION_LANDSCAPE: - if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) { - return PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT; - } else { - return PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT; - } - default: - return PlatformChannel.DeviceOrientation.PORTRAIT_UP; - } - } - - private PlatformChannel.DeviceOrientation calculateSensorOrientation(int angle) { - final int tolerance = 45; - angle += tolerance; - - // Orientation is 0 in the default orientation mode. This is portait-mode for phones - // and landscape for tablets. We have to compensate for this by calculating the default - // orientation, and apply an offset accordingly. - int defaultDeviceOrientation = getDeviceDefaultOrientation(); - if (defaultDeviceOrientation == Configuration.ORIENTATION_LANDSCAPE) { - angle += 90; - } - // Determine the orientation - angle = angle % 360; - return new PlatformChannel.DeviceOrientation[] { - PlatformChannel.DeviceOrientation.PORTRAIT_UP, - PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT, - PlatformChannel.DeviceOrientation.PORTRAIT_DOWN, - PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT, - } - [angle / 90]; - } - - private int getDeviceDefaultOrientation() { - Configuration config = activity.getResources().getConfiguration(); - int rotation = getDisplay().getRotation(); - if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) - && config.orientation == Configuration.ORIENTATION_LANDSCAPE) - || ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) - && config.orientation == Configuration.ORIENTATION_PORTRAIT)) { - return Configuration.ORIENTATION_LANDSCAPE; - } else { - return Configuration.ORIENTATION_PORTRAIT; - } - } - - @SuppressWarnings("deprecation") - private Display getDisplay() { - return ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); - } -} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index 50bca6349217..893785f1a58f 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -10,6 +10,8 @@ import android.os.Looper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.EventChannel; @@ -17,14 +19,17 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugins.camera.CameraPermissions.PermissionsRegistry; -import io.flutter.plugins.camera.types.ExposureMode; -import io.flutter.plugins.camera.types.FlashMode; -import io.flutter.plugins.camera.types.FocusMode; +import io.flutter.plugins.camera.features.CameraFeatureFactoryImpl; +import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.autofocus.FocusMode; +import io.flutter.plugins.camera.features.exposurelock.ExposureMode; +import io.flutter.plugins.camera.features.flash.FlashMode; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; import io.flutter.view.TextureRegistry; import java.util.HashMap; import java.util.Map; -final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler { +final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler, LifecycleObserver { private final Activity activity; private final BinaryMessenger messenger; private final CameraPermissions cameraPermissions; @@ -32,6 +37,7 @@ final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler { private final TextureRegistry textureRegistry; private final MethodChannel methodChannel; private final EventChannel imageStreamChannel; + private final Lifecycle lifecycle; private @Nullable Camera camera; MethodCallHandlerImpl( @@ -39,12 +45,14 @@ final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler { BinaryMessenger messenger, CameraPermissions cameraPermissions, PermissionsRegistry permissionsAdder, - TextureRegistry textureRegistry) { + TextureRegistry textureRegistry, + @Nullable Lifecycle lifecycle) { this.activity = activity; this.messenger = messenger; this.cameraPermissions = cameraPermissions; this.permissionsRegistry = permissionsAdder; this.textureRegistry = textureRegistry; + this.lifecycle = lifecycle; methodChannel = new MethodChannel(messenger, "plugins.flutter.io/camera"); imageStreamChannel = new EventChannel(messenger, "plugins.flutter.io/camera/imageStream"); @@ -172,7 +180,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) y = call.argument("y"); } try { - camera.setExposurePoint(result, x, y); + camera.setExposurePoint(result, new Point(x, y)); } catch (Exception e) { handleException(e, result); } @@ -239,7 +247,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) y = call.argument("y"); } try { - camera.setFocusPoint(result, x, y); + camera.setFocusPoint(result, new Point(x, y)); } catch (Exception e) { handleException(e, result); } @@ -351,22 +359,36 @@ void stopListening() { private void instantiateCamera(MethodCall call, Result result) throws CameraAccessException { String cameraName = call.argument("cameraName"); - String resolutionPreset = call.argument("resolutionPreset"); + String preset = call.argument("resolutionPreset"); boolean enableAudio = call.argument("enableAudio"); + TextureRegistry.SurfaceTextureEntry flutterSurfaceTexture = textureRegistry.createSurfaceTexture(); DartMessenger dartMessenger = new DartMessenger( messenger, flutterSurfaceTexture.id(), new Handler(Looper.getMainLooper())); + CameraProperties cameraProperties = + new CameraPropertiesImpl(cameraName, CameraUtils.getCameraManager(activity)); + ResolutionPreset resolutionPreset = ResolutionPreset.valueOf(preset); + + if (camera != null && lifecycle != null) { + lifecycle.removeObserver(camera); + } + camera = new Camera( activity, flutterSurfaceTexture, + new CameraFeatureFactoryImpl(), dartMessenger, - cameraName, + cameraProperties, resolutionPreset, enableAudio); + if (lifecycle != null) { + lifecycle.addObserver(camera); + } + Map reply = new HashMap<>(); reply.put("cameraId", flutterSurfaceTexture.id()); result.success(reply); diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/PictureCaptureRequest.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/PictureCaptureRequest.java deleted file mode 100644 index 4c11e2d40e62..000000000000 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/PictureCaptureRequest.java +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera; - -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.Nullable; -import io.flutter.plugin.common.MethodChannel; - -class PictureCaptureRequest { - - enum State { - idle, - focusing, - preCapture, - waitingPreCaptureReady, - capturing, - finished, - error, - } - - private final Runnable timeoutCallback = - new Runnable() { - @Override - public void run() { - error("captureTimeout", "Picture capture request timed out", state.toString()); - } - }; - - private final MethodChannel.Result result; - private final TimeoutHandler timeoutHandler; - private State state; - - public PictureCaptureRequest(MethodChannel.Result result) { - this(result, new TimeoutHandler()); - } - - public PictureCaptureRequest(MethodChannel.Result result, TimeoutHandler timeoutHandler) { - this.result = result; - this.state = State.idle; - this.timeoutHandler = timeoutHandler; - } - - public void setState(State state) { - if (isFinished()) throw new IllegalStateException("Request has already been finished"); - this.state = state; - if (state != State.idle && state != State.finished && state != State.error) { - this.timeoutHandler.resetTimeout(timeoutCallback); - } else { - this.timeoutHandler.clearTimeout(timeoutCallback); - } - } - - public State getState() { - return state; - } - - public boolean isFinished() { - return state == State.finished || state == State.error; - } - - public void finish(String absolutePath) { - if (isFinished()) throw new IllegalStateException("Request has already been finished"); - this.timeoutHandler.clearTimeout(timeoutCallback); - result.success(absolutePath); - state = State.finished; - } - - public void error( - String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { - if (isFinished()) throw new IllegalStateException("Request has already been finished"); - this.timeoutHandler.clearTimeout(timeoutCallback); - result.error(errorCode, errorMessage, errorDetails); - state = State.error; - } - - static class TimeoutHandler { - private static final int REQUEST_TIMEOUT = 5000; - private final Handler handler; - - TimeoutHandler() { - this.handler = new Handler(Looper.getMainLooper()); - } - - public void resetTimeout(Runnable runnable) { - clearTimeout(runnable); - handler.postDelayed(runnable, REQUEST_TIMEOUT); - } - - public void clearTimeout(Runnable runnable) { - handler.removeCallbacks(runnable); - } - } -} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java index 8d10c445788c..b91f9a1c03f7 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java @@ -84,9 +84,13 @@ ResolutionFeature createResolutionFeature( * * @param cameraProperties instance of the CameraProperties class containing information about the * cameras features. + * @param sensorOrientationFeature instance of the SensorOrientationFeature class containing + * information about the sensor and device orientation. * @return newly created instance of the FocusPointFeature class. */ - FocusPointFeature createFocusPointFeature(@NonNull CameraProperties cameraProperties); + FocusPointFeature createFocusPointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature); /** * Creates a new instance of the FPS range feature. @@ -126,9 +130,13 @@ SensorOrientationFeature createSensorOrientationFeature( * * @param cameraProperties instance of the CameraProperties class containing information about the * cameras features. + * @param sensorOrientationFeature instance of the SensorOrientationFeature class containing + * information about the sensor and device orientation. * @return newly created instance of the ExposurePointFeature class. */ - ExposurePointFeature createExposurePointFeature(@NonNull CameraProperties cameraProperties); + ExposurePointFeature createExposurePointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature); /** * Creates a new instance of the noise reduction feature. diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java index b12ad3626226..95a8c06caa0a 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java @@ -59,8 +59,10 @@ public ResolutionFeature createResolutionFeature( } @Override - public FocusPointFeature createFocusPointFeature(@NonNull CameraProperties cameraProperties) { - return new FocusPointFeature(cameraProperties); + public FocusPointFeature createFocusPointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature) { + return new FocusPointFeature(cameraProperties, sensorOrientationFeature); } @Override @@ -83,8 +85,9 @@ public ZoomLevelFeature createZoomLevelFeature(@NonNull CameraProperties cameraP @Override public ExposurePointFeature createExposurePointFeature( - @NonNull CameraProperties cameraProperties) { - return new ExposurePointFeature(cameraProperties); + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature) { + return new ExposurePointFeature(cameraProperties, sensorOrientationFeature); } @Override diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java index 0ee8969071bc..659fd15963e9 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java @@ -4,6 +4,9 @@ package io.flutter.plugins.camera.features; +import android.app.Activity; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; @@ -13,6 +16,7 @@ import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; import java.util.Collection; @@ -37,6 +41,39 @@ public class CameraFeatures { private static final String SENSOR_ORIENTATION = "SENSOR_ORIENTATION"; private static final String ZOOM_LEVEL = "ZOOM_LEVEL"; + public static CameraFeatures init( + CameraFeatureFactory cameraFeatureFactory, + CameraProperties cameraProperties, + Activity activity, + DartMessenger dartMessenger, + ResolutionPreset resolutionPreset) { + CameraFeatures cameraFeatures = new CameraFeatures(); + cameraFeatures.setAutoFocus( + cameraFeatureFactory.createAutoFocusFeature(cameraProperties, false)); + cameraFeatures.setExposureLock( + cameraFeatureFactory.createExposureLockFeature(cameraProperties)); + cameraFeatures.setExposureOffset( + cameraFeatureFactory.createExposureOffsetFeature(cameraProperties)); + SensorOrientationFeature sensorOrientationFeature = + cameraFeatureFactory.createSensorOrientationFeature( + cameraProperties, activity, dartMessenger); + cameraFeatures.setSensorOrientation(sensorOrientationFeature); + cameraFeatures.setExposurePoint( + cameraFeatureFactory.createExposurePointFeature( + cameraProperties, sensorOrientationFeature)); + cameraFeatures.setFlash(cameraFeatureFactory.createFlashFeature(cameraProperties)); + cameraFeatures.setFocusPoint( + cameraFeatureFactory.createFocusPointFeature(cameraProperties, sensorOrientationFeature)); + cameraFeatures.setFpsRange(cameraFeatureFactory.createFpsRangeFeature(cameraProperties)); + cameraFeatures.setNoiseReduction( + cameraFeatureFactory.createNoiseReductionFeature(cameraProperties)); + cameraFeatures.setResolution( + cameraFeatureFactory.createResolutionFeature( + cameraProperties, resolutionPreset, cameraProperties.getCameraName())); + cameraFeatures.setZoomLevel(cameraFeatureFactory.createZoomLevelFeature(cameraProperties)); + return cameraFeatures; + } + private Map featureMap = new HashMap<>(); /** diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java index 8c2ee6167846..336e756e9ed8 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeature.java @@ -8,10 +8,12 @@ import android.hardware.camera2.params.MeteringRectangle; import android.util.Size; import androidx.annotation.NonNull; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugins.camera.CameraProperties; import io.flutter.plugins.camera.CameraRegionUtils; import io.flutter.plugins.camera.features.CameraFeature; import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; /** Exposure point controls where in the frame exposure metering will come from. */ public class ExposurePointFeature extends CameraFeature { @@ -19,14 +21,17 @@ public class ExposurePointFeature extends CameraFeature { private Size cameraBoundaries; private Point exposurePoint; private MeteringRectangle exposureRectangle; + private final SensorOrientationFeature sensorOrientationFeature; /** * Creates a new instance of the {@link ExposurePointFeature}. * * @param cameraProperties Collection of the characteristics for the current camera device. */ - public ExposurePointFeature(CameraProperties cameraProperties) { + public ExposurePointFeature( + CameraProperties cameraProperties, SensorOrientationFeature sensorOrientationFeature) { super(cameraProperties); + this.sensorOrientationFeature = sensorOrientationFeature; } /** @@ -80,9 +85,15 @@ private void buildExposureRectangle() { if (this.exposurePoint == null) { this.exposureRectangle = null; } else { + PlatformChannel.DeviceOrientation orientation = + this.sensorOrientationFeature.getLockedCaptureOrientation(); + if (orientation == null) { + orientation = + this.sensorOrientationFeature.getDeviceOrientationManager().getLastUIOrientation(); + } this.exposureRectangle = CameraRegionUtils.convertPointToMeteringRectangle( - this.cameraBoundaries, this.exposurePoint.x, this.exposurePoint.y); + this.cameraBoundaries, this.exposurePoint.x, this.exposurePoint.y, orientation); } } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java index 92fcfa9f1132..a3a0172d3c37 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java @@ -8,10 +8,12 @@ import android.hardware.camera2.params.MeteringRectangle; import android.util.Size; import androidx.annotation.NonNull; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugins.camera.CameraProperties; import io.flutter.plugins.camera.CameraRegionUtils; import io.flutter.plugins.camera.features.CameraFeature; import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; /** Focus point controls where in the frame focus will come from. */ public class FocusPointFeature extends CameraFeature { @@ -19,14 +21,17 @@ public class FocusPointFeature extends CameraFeature { private Size cameraBoundaries; private Point focusPoint; private MeteringRectangle focusRectangle; + private final SensorOrientationFeature sensorOrientationFeature; /** * Creates a new instance of the {@link FocusPointFeature}. * * @param cameraProperties Collection of the characteristics for the current camera device. */ - public FocusPointFeature(CameraProperties cameraProperties) { + public FocusPointFeature( + CameraProperties cameraProperties, SensorOrientationFeature sensorOrientationFeature) { super(cameraProperties); + this.sensorOrientationFeature = sensorOrientationFeature; } /** @@ -80,9 +85,15 @@ private void buildFocusRectangle() { if (this.focusPoint == null) { this.focusRectangle = null; } else { + PlatformChannel.DeviceOrientation orientation = + this.sensorOrientationFeature.getLockedCaptureOrientation(); + if (orientation == null) { + orientation = + this.sensorOrientationFeature.getDeviceOrientationManager().getLastUIOrientation(); + } this.focusRectangle = CameraRegionUtils.convertPointToMeteringRectangle( - this.cameraBoundaries, this.focusPoint.x, this.focusPoint.y); + this.cameraBoundaries, this.focusPoint.x, this.focusPoint.y, orientation); } } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java index 847a817641ab..408575b375e6 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java @@ -20,9 +20,15 @@ public class NoiseReductionFeature extends CameraFeature { private NoiseReductionMode currentSetting = NoiseReductionMode.fast; - private static final HashMap NOISE_REDUCTION_MODES = new HashMap<>(); + private final HashMap NOISE_REDUCTION_MODES = new HashMap<>(); - static { + /** + * Creates a new instance of the {@link NoiseReductionFeature}. + * + * @param cameraProperties Collection of the characteristics for the current camera device. + */ + public NoiseReductionFeature(CameraProperties cameraProperties) { + super(cameraProperties); NOISE_REDUCTION_MODES.put(NoiseReductionMode.off, CaptureRequest.NOISE_REDUCTION_MODE_OFF); NOISE_REDUCTION_MODES.put(NoiseReductionMode.fast, CaptureRequest.NOISE_REDUCTION_MODE_FAST); NOISE_REDUCTION_MODES.put( @@ -35,15 +41,6 @@ public class NoiseReductionFeature extends CameraFeature { } } - /** - * Creates a new instance of the {@link NoiseReductionFeature}. - * - * @param cameraProperties Collection of the characteristics for the current camera device. - */ - public NoiseReductionFeature(CameraProperties cameraProperties) { - super(cameraProperties); - } - @Override public String getDebugName() { return "NoiseReductionFeature"; diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java index 2a04caad743a..dd1e489e6225 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManager.java @@ -10,10 +10,7 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.res.Configuration; -import android.hardware.SensorManager; -import android.provider.Settings; import android.view.Display; -import android.view.OrientationEventListener; import android.view.Surface; import android.view.WindowManager; import androidx.annotation.NonNull; @@ -35,7 +32,6 @@ public class DeviceOrientationManager { private final boolean isFrontFacing; private final int sensorOrientation; private PlatformChannel.DeviceOrientation lastOrientation; - private OrientationEventListener orientationEventListener; private BroadcastReceiver broadcastReceiver; /** Factory method to create a device orientation manager. */ @@ -63,7 +59,7 @@ private DeviceOrientationManager( * *

    When orientation information is updated the new orientation is send to the client using the * {@link DartMessenger}. This latest value can also be retrieved through the {@link - * #getMediaOrientation()} accessor. + * #getVideoOrientation()} accessor. * *

    If the device's ACCELEROMETER_ROTATION setting is enabled the {@link * DeviceOrientationManager} will report orientation updates based on the sensor information. If @@ -71,55 +67,106 @@ private DeviceOrientationManager( * the deliver orientation updates based on the UI orientation. */ public void start() { - startSensorListener(); - startUIListener(); + if (broadcastReceiver != null) { + return; + } + broadcastReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + handleUIOrientationChange(); + } + }; + activity.registerReceiver(broadcastReceiver, orientationIntentFilter); + broadcastReceiver.onReceive(activity, null); } /** Stops listening for orientation updates. */ public void stop() { - stopSensorListener(); - stopUIListener(); + if (broadcastReceiver == null) { + return; + } + activity.unregisterReceiver(broadcastReceiver); + broadcastReceiver = null; } /** - * Returns the last captured orientation in degrees based on sensor or UI information. + * Returns the device's photo orientation in degrees based on the sensor orientation and the last + * known UI orientation. * - *

    The orientation is returned in degrees and could be one of the following values: + *

    Returns one of 0, 90, 180 or 270. * - *

      - *
    • 0: Indicates the device is currently in portrait. - *
    • 90: Indicates the device is currently in landscape left. - *
    • 180: Indicates the device is currently in portrait down. - *
    • 270: Indicates the device is currently in landscape right. - *
    + * @return The device's photo orientation in degrees. + */ + public int getPhotoOrientation() { + return this.getPhotoOrientation(this.lastOrientation); + } + + /** + * Returns the device's photo orientation in degrees based on the sensor orientation and the + * supplied {@link PlatformChannel.DeviceOrientation} value. * - * @return The last captured orientation in degrees + *

    Returns one of 0, 90, 180 or 270. + * + * @param orientation The {@link PlatformChannel.DeviceOrientation} value that is to be converted + * into degrees. + * @return The device's photo orientation in degrees. */ - public int getMediaOrientation() { - return this.getMediaOrientation(this.lastOrientation); + public int getPhotoOrientation(PlatformChannel.DeviceOrientation orientation) { + int angle = 0; + // Fallback to device orientation when the orientation value is null. + if (orientation == null) { + orientation = getUIOrientation(); + } + + switch (orientation) { + case PORTRAIT_UP: + angle = 90; + break; + case PORTRAIT_DOWN: + angle = 270; + break; + case LANDSCAPE_LEFT: + angle = isFrontFacing ? 180 : 0; + break; + case LANDSCAPE_RIGHT: + angle = isFrontFacing ? 0 : 180; + break; + } + + // Sensor orientation is 90 for most devices, or 270 for some devices (eg. Nexus 5X). + // This has to be taken into account so the JPEG is rotated properly. + // For devices with orientation of 90, this simply returns the mapping from ORIENTATIONS. + // For devices with orientation of 270, the JPEG is rotated 180 degrees instead. + return (angle + sensorOrientation + 270) % 360; } /** - * Returns the device's orientation in degrees based on the supplied {@link - * PlatformChannel.DeviceOrientation} value. + * Returns the device's video orientation in degrees based on the sensor orientation and the last + * known UI orientation. + * + *

    Returns one of 0, 90, 180 or 270. * - *

    + * @return The device's video orientation in degrees. + */ + public int getVideoOrientation() { + return this.getVideoOrientation(this.lastOrientation); + } + + /** + * Returns the device's video orientation in degrees based on the sensor orientation and the + * supplied {@link PlatformChannel.DeviceOrientation} value. * - *

      - *
    • PORTRAIT_UP: converts to 0 degrees. - *
    • LANDSCAPE_LEFT: converts to 90 degrees. - *
    • PORTRAIT_DOWN: converts to 180 degrees. - *
    • LANDSCAPE_RIGHT: converts to 270 degrees. - *
    + *

    Returns one of 0, 90, 180 or 270. * * @param orientation The {@link PlatformChannel.DeviceOrientation} value that is to be converted * into degrees. - * @return The device's orientation in degrees. + * @return The device's video orientation in degrees. */ - public int getMediaOrientation(PlatformChannel.DeviceOrientation orientation) { + public int getVideoOrientation(PlatformChannel.DeviceOrientation orientation) { int angle = 0; - // Fallback to device orientation when the orientation value is null + // Fallback to device orientation when the orientation value is null. if (orientation == null) { orientation = getUIOrientation(); } @@ -146,51 +193,9 @@ public int getMediaOrientation(PlatformChannel.DeviceOrientation orientation) { return (angle + sensorOrientation + 360) % 360; } - private void startSensorListener() { - if (orientationEventListener != null) { - return; - } - orientationEventListener = - new OrientationEventListener(activity, SensorManager.SENSOR_DELAY_NORMAL) { - @Override - public void onOrientationChanged(int angle) { - handleSensorOrientationChange(angle); - } - }; - if (orientationEventListener.canDetectOrientation()) { - orientationEventListener.enable(); - } - } - - private void startUIListener() { - if (broadcastReceiver != null) { - return; - } - broadcastReceiver = - new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - handleUIOrientationChange(); - } - }; - activity.registerReceiver(broadcastReceiver, orientationIntentFilter); - broadcastReceiver.onReceive(activity, null); - } - - /** - * Handles orientation changes based on information from the device's sensors. - * - *

    This method is visible for testing purposes only and should never be used outside this - * class. - * - * @param angle of the current orientation. - */ - @VisibleForTesting - void handleSensorOrientationChange(int angle) { - if (!isAccelerometerRotationLocked()) { - PlatformChannel.DeviceOrientation orientation = calculateSensorOrientation(angle); - lastOrientation = handleOrientationChange(orientation, lastOrientation, messenger); - } + /** @return the last received UI orientation. */ + public PlatformChannel.DeviceOrientation getLastUIOrientation() { + return this.lastOrientation; } /** @@ -201,10 +206,9 @@ void handleSensorOrientationChange(int angle) { */ @VisibleForTesting void handleUIOrientationChange() { - if (isAccelerometerRotationLocked()) { - PlatformChannel.DeviceOrientation orientation = getUIOrientation(); - lastOrientation = handleOrientationChange(orientation, lastOrientation, messenger); - } + PlatformChannel.DeviceOrientation orientation = getUIOrientation(); + handleOrientationChange(orientation, lastOrientation, messenger); + lastOrientation = orientation; } /** @@ -215,37 +219,13 @@ void handleUIOrientationChange() { * class. */ @VisibleForTesting - static DeviceOrientation handleOrientationChange( + static void handleOrientationChange( DeviceOrientation newOrientation, DeviceOrientation previousOrientation, DartMessenger messenger) { if (!newOrientation.equals(previousOrientation)) { messenger.sendDeviceOrientationChangeEvent(newOrientation); } - - return newOrientation; - } - - private void stopSensorListener() { - if (orientationEventListener == null) { - return; - } - orientationEventListener.disable(); - orientationEventListener = null; - } - - private void stopUIListener() { - if (broadcastReceiver == null) { - return; - } - activity.unregisterReceiver(broadcastReceiver); - broadcastReceiver = null; - } - - private boolean isAccelerometerRotationLocked() { - return android.provider.Settings.System.getInt( - activity.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) - != 1; } /** diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraPropertiesImplTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraPropertiesImplTest.java index 2c0381744191..40db12ee0fc3 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraPropertiesImplTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraPropertiesImplTest.java @@ -41,7 +41,7 @@ public void before() { } @Test - public void ctor_Should_return_valid_instance() throws CameraAccessException { + public void ctor_shouldReturnValidInstance() throws CameraAccessException { verify(mockCameraManager, times(1)).getCameraCharacteristics(CAMERA_NAME); assertNotNull(cameraProperties); } @@ -76,8 +76,7 @@ public void getControlAutoExposureCompensationRangeTest() { } @Test - public void - getControlAutoExposureCompensationStep_Should_return_double_When_rational_is_not_null() { + public void getControlAutoExposureCompensationStep_shouldReturnDoubleWhenRationalIsNotNull() { double expectedStep = 3.1415926535; Rational mockRational = mock(Rational.class); @@ -92,7 +91,7 @@ public void getControlAutoExposureCompensationRangeTest() { } @Test - public void getControlAutoExposureCompensationStep_Should_return_zero_When_rational_is_null() { + public void getControlAutoExposureCompensationStep_shouldReturnZeroWhenRationalIsNull() { double expectedStep = 0.0; when(mockCharacteristics.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_STEP)) diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_convertPointToMeteringRectangleTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_convertPointToMeteringRectangleTest.java new file mode 100644 index 000000000000..2c6d9d9177e9 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_convertPointToMeteringRectangleTest.java @@ -0,0 +1,197 @@ +// Copyright 2013 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. +package io.flutter.plugins.camera; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +public class CameraRegionUtils_convertPointToMeteringRectangleTest { + private MockedStatic mockedMeteringRectangleFactory; + private Size mockCameraBoundaries; + + @Before + public void setUp() { + this.mockCameraBoundaries = mock(Size.class); + when(this.mockCameraBoundaries.getWidth()).thenReturn(100); + when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + mockedMeteringRectangleFactory = mockStatic(CameraRegionUtils.MeteringRectangleFactory.class); + + mockedMeteringRectangleFactory + .when( + () -> + CameraRegionUtils.MeteringRectangleFactory.create( + anyInt(), anyInt(), anyInt(), anyInt(), anyInt())) + .thenAnswer( + new Answer() { + @Override + public MeteringRectangle answer(InvocationOnMock createInvocation) throws Throwable { + MeteringRectangle mockMeteringRectangle = mock(MeteringRectangle.class); + when(mockMeteringRectangle.getX()).thenReturn(createInvocation.getArgument(0)); + when(mockMeteringRectangle.getY()).thenReturn(createInvocation.getArgument(1)); + when(mockMeteringRectangle.getWidth()).thenReturn(createInvocation.getArgument(2)); + when(mockMeteringRectangle.getHeight()).thenReturn(createInvocation.getArgument(3)); + when(mockMeteringRectangle.getMeteringWeight()) + .thenReturn(createInvocation.getArgument(4)); + when(mockMeteringRectangle.equals(any())) + .thenAnswer( + new Answer() { + @Override + public Boolean answer(InvocationOnMock equalsInvocation) + throws Throwable { + MeteringRectangle otherMockMeteringRectangle = + equalsInvocation.getArgument(0); + return mockMeteringRectangle.getX() == otherMockMeteringRectangle.getX() + && mockMeteringRectangle.getY() == otherMockMeteringRectangle.getY() + && mockMeteringRectangle.getWidth() + == otherMockMeteringRectangle.getWidth() + && mockMeteringRectangle.getHeight() + == otherMockMeteringRectangle.getHeight() + && mockMeteringRectangle.getMeteringWeight() + == otherMockMeteringRectangle.getMeteringWeight(); + } + }); + return mockMeteringRectangle; + } + }); + } + + @After + public void tearDown() { + mockedMeteringRectangleFactory.close(); + } + + @Test + public void convertPointToMeteringRectangle_shouldReturnValidMeteringRectangleForCenterCoord() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 0.5, 0.5, PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(45, 45, 10, 10, 1).equals(r)); + } + + @Test + public void convertPointToMeteringRectangle_shouldReturnValidMeteringRectangleForTopLeftCoord() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 0, 0, PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 0, 10, 10, 1).equals(r)); + } + + @Test + public void convertPointToMeteringRectangle_ShouldReturnValidMeteringRectangleForTopRightCoord() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1, 0, PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 0, 10, 10, 1).equals(r)); + } + + @Test + public void + convertPointToMeteringRectangle_shouldReturnValidMeteringRectangleForBottomLeftCoord() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 0, 1, PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 89, 10, 10, 1).equals(r)); + } + + @Test + public void + convertPointToMeteringRectangle_shouldReturnValidMeteringRectangleForBottomRightCoord() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1, 1, PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 89, 10, 10, 1).equals(r)); + } + + @Test(expected = AssertionError.class) + public void convertPointToMeteringRectangle_shouldThrowForXUpperBound() { + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1.5, 0, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } + + @Test(expected = AssertionError.class) + public void convertPointToMeteringRectangle_shouldThrowForXLowerBound() { + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, -0.5, 0, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } + + @Test(expected = AssertionError.class) + public void convertPointToMeteringRectangle_shouldThrowForYUpperBound() { + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 0, 1.5, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } + + @Test(expected = AssertionError.class) + public void convertPointToMeteringRectangle_shouldThrowForYLowerBound() { + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 0, -0.5, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } + + @Test() + public void + convertPointToMeteringRectangle_shouldRotateMeteringRectangleAccordingToUiOrientationForPortraitUp() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1, 1, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 0, 10, 10, 1).equals(r)); + } + + @Test() + public void + convertPointToMeteringRectangle_shouldRotateMeteringRectangleAccordingToUiOrientationForPortraitDown() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1, 1, PlatformChannel.DeviceOrientation.PORTRAIT_DOWN); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 89, 10, 10, 1).equals(r)); + } + + @Test() + public void + convertPointToMeteringRectangle_shouldRotateMeteringRectangleAccordingToUiOrientationForLandscapeLeft() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1, 1, PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 89, 10, 10, 1).equals(r)); + } + + @Test() + public void + convertPointToMeteringRectangle_shouldRotateMeteringRectangleAccordingToUiOrientationForLandscapeRight() { + MeteringRectangle r = + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 1, 1, PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT); + assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 0, 10, 10, 1).equals(r)); + } + + @Test(expected = AssertionError.class) + public void convertPointToMeteringRectangle_shouldThrowFor0WidthBoundary() { + Size mockCameraBoundaries = mock(Size.class); + when(mockCameraBoundaries.getWidth()).thenReturn(0); + when(mockCameraBoundaries.getHeight()).thenReturn(50); + CameraRegionUtils.convertPointToMeteringRectangle( + mockCameraBoundaries, 0, -0.5, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } + + @Test(expected = AssertionError.class) + public void convertPointToMeteringRectangle_shouldThrowFor0HeightBoundary() { + Size mockCameraBoundaries = mock(Size.class); + when(mockCameraBoundaries.getWidth()).thenReturn(50); + when(mockCameraBoundaries.getHeight()).thenReturn(0); + CameraRegionUtils.convertPointToMeteringRectangle( + this.mockCameraBoundaries, 0, -0.5, PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_getCameraBoundariesTest.java similarity index 61% rename from packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java rename to packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_getCameraBoundariesTest.java index 2d65c4e0fc05..4c0164981b74 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtilsTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_getCameraBoundariesTest.java @@ -4,8 +4,6 @@ package io.flutter.plugins.camera; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -15,17 +13,15 @@ import android.graphics.Rect; import android.hardware.camera2.CaptureRequest; -import android.hardware.camera2.params.MeteringRectangle; import android.os.Build; import android.util.Size; import io.flutter.plugins.camera.utils.TestUtils; import org.junit.Before; import org.junit.Test; import org.mockito.MockedStatic; -import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; -public class CameraRegionUtilsTest { +public class CameraRegionUtils_getCameraBoundariesTest { Size mockCameraBoundaries; @@ -37,8 +33,7 @@ public void setUp() { } @Test - public void - getCameraBoundaries_should_return_sensor_info_pixel_array_size_when_running_pre_android_p() { + public void getCameraBoundaries_shouldReturnSensorInfoPixelArraySizeWhenRunningPreAndroidP() { updateSdkVersion(Build.VERSION_CODES.O_MR1); try { @@ -58,7 +53,7 @@ public void setUp() { @Test public void - getCameraBoundaries_should_return_sensor_info_pixel_array_size_when_distortion_correction_is_null() { + getCameraBoundaries_shouldReturnSensorInfoPixelArraySizeWhenDistortionCorrectionIsNull() { updateSdkVersion(Build.VERSION_CODES.P); try { @@ -80,7 +75,7 @@ public void setUp() { @Test public void - getCameraBoundaries_should_return_sensor_info_pixel_array_size_when_distortion_correction_is_off() { + getCameraBoundaries_shouldReturnSensorInfoPixelArraySizeWhenDistortionCorrectionIsOff() { updateSdkVersion(Build.VERSION_CODES.P); try { @@ -103,7 +98,7 @@ public void setUp() { @Test public void - getCameraBoundaries_should_return_info_pre_correction_active_array_size_when_distortion_correction_mode_is_set_to_null() { + getCameraBoundaries_shouldReturnInfoPreCorrectionActiveArraySizeWhenDistortionCorrectionModeIsSetToNull() { updateSdkVersion(Build.VERSION_CODES.P); try { @@ -150,7 +145,7 @@ public void setUp() { @Test public void - getCameraBoundaries_should_return_info_pre_correction_active_array_size_when_distortion_correction_mode_is_set_to_off() { + getCameraBoundaries_shouldReturnInfoPreCorrectionActiveArraySizeWhenDistortionCorrectionModeIsSetToOff() { updateSdkVersion(Build.VERSION_CODES.P); try { @@ -199,7 +194,7 @@ public void setUp() { @Test public void - getCameraBoundaries_should_return_sensor_info_active_array_size_when_distortion_correction_mode_is_set() { + getCameraBoundaries_shouldReturnSensorInfoActiveArraySizeWhenDistortionCorrectionModeIsSet() { updateSdkVersion(Build.VERSION_CODES.P); try { @@ -246,107 +241,6 @@ public void setUp() { } } - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_x_upper_bound() { - CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 1.5, 0); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_x_lower_bound() { - CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, -0.5, 0); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_y_upper_bound() { - CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0, 1.5); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_y_lower_bound() { - CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0, -0.5); - } - - @Test - public void getMeteringRectangleForPoint_should_return_valid_MeteringRectangle() { - try (MockedStatic mockedMeteringRectangleFactory = - mockStatic(CameraRegionUtils.MeteringRectangleFactory.class)) { - - mockedMeteringRectangleFactory - .when( - () -> - CameraRegionUtils.MeteringRectangleFactory.create( - anyInt(), anyInt(), anyInt(), anyInt(), anyInt())) - .thenAnswer( - new Answer() { - @Override - public MeteringRectangle answer(InvocationOnMock createInvocation) - throws Throwable { - MeteringRectangle mockMeteringRectangle = mock(MeteringRectangle.class); - when(mockMeteringRectangle.getX()).thenReturn(createInvocation.getArgument(0)); - when(mockMeteringRectangle.getY()).thenReturn(createInvocation.getArgument(1)); - when(mockMeteringRectangle.getWidth()) - .thenReturn(createInvocation.getArgument(2)); - when(mockMeteringRectangle.getHeight()) - .thenReturn(createInvocation.getArgument(3)); - when(mockMeteringRectangle.getMeteringWeight()) - .thenReturn(createInvocation.getArgument(4)); - when(mockMeteringRectangle.equals(any())) - .thenAnswer( - new Answer() { - @Override - public Boolean answer(InvocationOnMock equalsInvocation) - throws Throwable { - MeteringRectangle otherMockMeteringRectangle = - equalsInvocation.getArgument(0); - return mockMeteringRectangle.getX() - == otherMockMeteringRectangle.getX() - && mockMeteringRectangle.getY() - == otherMockMeteringRectangle.getY() - && mockMeteringRectangle.getWidth() - == otherMockMeteringRectangle.getWidth() - && mockMeteringRectangle.getHeight() - == otherMockMeteringRectangle.getHeight() - && mockMeteringRectangle.getMeteringWeight() - == otherMockMeteringRectangle.getMeteringWeight(); - } - }); - return mockMeteringRectangle; - } - }); - - MeteringRectangle r; - // Center - r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0.5, 0.5); - assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(45, 45, 10, 10, 1).equals(r)); - - // Top left - r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0.0, 0.0); - assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 0, 10, 10, 1).equals(r)); - - // Bottom right - r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 1.0, 1.0); - assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 89, 10, 10, 1).equals(r)); - - // Top left - r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 0.0, 1.0); - assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(0, 89, 10, 10, 1).equals(r)); - - // Top right - r = CameraRegionUtils.convertPointToMeteringRectangle(this.mockCameraBoundaries, 1.0, 0.0); - assertTrue(CameraRegionUtils.MeteringRectangleFactory.create(89, 0, 10, 10, 1).equals(r)); - } - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_0_width_boundary() { - new io.flutter.plugins.camera.CameraRegions(new Size(0, 50)); - } - - @Test(expected = AssertionError.class) - public void getMeteringRectangleForPoint_should_throw_for_0_height_boundary() { - new io.flutter.plugins.camera.CameraRegions(new Size(100, 0)); - } - private static void updateSdkVersion(int version) { TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", version); } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java new file mode 100644 index 000000000000..cab2ae8974a4 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -0,0 +1,843 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraMetadata; +import android.hardware.camera2.CaptureRequest; +import android.media.CamcorderProfile; +import android.media.MediaRecorder; +import android.os.Build; +import androidx.annotation.NonNull; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.camera.features.CameraFeatureFactory; +import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.autofocus.FocusMode; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureMode; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.flash.FlashMode; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.DeviceOrientationManager; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; +import io.flutter.plugins.camera.utils.TestUtils; +import io.flutter.view.TextureRegistry; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class CameraTest { + private CameraProperties mockCameraProperties; + private CameraFeatureFactory mockCameraFeatureFactory; + private DartMessenger mockDartMessenger; + private Camera camera; + private CameraCaptureSession mockCaptureSession; + private CaptureRequest.Builder mockPreviewRequestBuilder; + + @Before + public void before() { + mockCameraProperties = mock(CameraProperties.class); + mockCameraFeatureFactory = new TestCameraFeatureFactory(); + mockDartMessenger = mock(DartMessenger.class); + mockCaptureSession = mock(CameraCaptureSession.class); + mockPreviewRequestBuilder = mock(CaptureRequest.Builder.class); + + final Activity mockActivity = mock(Activity.class); + final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = + mock(TextureRegistry.SurfaceTextureEntry.class); + final String cameraName = "1"; + final ResolutionPreset resolutionPreset = ResolutionPreset.high; + final boolean enableAudio = false; + + when(mockCameraProperties.getCameraName()).thenReturn(cameraName); + + camera = + new Camera( + mockActivity, + mockFlutterTexture, + mockCameraFeatureFactory, + mockDartMessenger, + mockCameraProperties, + resolutionPreset, + enableAudio); + + TestUtils.setPrivateField(camera, "captureSession", mockCaptureSession); + TestUtils.setPrivateField(camera, "previewRequestBuilder", mockPreviewRequestBuilder); + } + + @After + public void after() { + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 0); + } + + @Test + public void shouldCreateCameraPluginAndSetAllFeatures() { + final Activity mockActivity = mock(Activity.class); + final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = + mock(TextureRegistry.SurfaceTextureEntry.class); + final CameraFeatureFactory mockCameraFeatureFactory = mock(CameraFeatureFactory.class); + final String cameraName = "1"; + final ResolutionPreset resolutionPreset = ResolutionPreset.high; + final boolean enableAudio = false; + + when(mockCameraProperties.getCameraName()).thenReturn(cameraName); + SensorOrientationFeature mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + when(mockCameraFeatureFactory.createSensorOrientationFeature(any(), any(), any())) + .thenReturn(mockSensorOrientationFeature); + + Camera camera = + new Camera( + mockActivity, + mockFlutterTexture, + mockCameraFeatureFactory, + mockDartMessenger, + mockCameraProperties, + resolutionPreset, + enableAudio); + + verify(mockCameraFeatureFactory, times(1)) + .createSensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + verify(mockCameraFeatureFactory, times(1)).createAutoFocusFeature(mockCameraProperties, false); + verify(mockCameraFeatureFactory, times(1)).createExposureLockFeature(mockCameraProperties); + verify(mockCameraFeatureFactory, times(1)) + .createExposurePointFeature(eq(mockCameraProperties), eq(mockSensorOrientationFeature)); + verify(mockCameraFeatureFactory, times(1)).createExposureOffsetFeature(mockCameraProperties); + verify(mockCameraFeatureFactory, times(1)).createFlashFeature(mockCameraProperties); + verify(mockCameraFeatureFactory, times(1)) + .createFocusPointFeature(eq(mockCameraProperties), eq(mockSensorOrientationFeature)); + verify(mockCameraFeatureFactory, times(1)).createFpsRangeFeature(mockCameraProperties); + verify(mockCameraFeatureFactory, times(1)).createNoiseReductionFeature(mockCameraProperties); + verify(mockCameraFeatureFactory, times(1)) + .createResolutionFeature(mockCameraProperties, resolutionPreset, cameraName); + verify(mockCameraFeatureFactory, times(1)).createZoomLevelFeature(mockCameraProperties); + assertNotNull("should create a camera", camera); + } + + @Test + public void getDeviceOrientationManager() { + SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature(mockCameraProperties, null, null); + DeviceOrientationManager mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + + DeviceOrientationManager actualDeviceOrientationManager = camera.getDeviceOrientationManager(); + + verify(mockSensorOrientationFeature, times(1)).getDeviceOrientationManager(); + assertEquals(mockDeviceOrientationManager, actualDeviceOrientationManager); + } + + @Test + public void getExposureOffsetStepSize() { + ExposureOffsetFeature mockExposureOffsetFeature = + mockCameraFeatureFactory.createExposureOffsetFeature(mockCameraProperties); + double stepSize = 2.3; + + when(mockExposureOffsetFeature.getExposureOffsetStepSize()).thenReturn(stepSize); + + double actualSize = camera.getExposureOffsetStepSize(); + + verify(mockExposureOffsetFeature, times(1)).getExposureOffsetStepSize(); + assertEquals(stepSize, actualSize, 0); + } + + @Test + public void getMaxExposureOffset() { + ExposureOffsetFeature mockExposureOffsetFeature = + mockCameraFeatureFactory.createExposureOffsetFeature(mockCameraProperties); + double expectedMaxOffset = 42.0; + + when(mockExposureOffsetFeature.getMaxExposureOffset()).thenReturn(expectedMaxOffset); + + double actualMaxOffset = camera.getMaxExposureOffset(); + + verify(mockExposureOffsetFeature, times(1)).getMaxExposureOffset(); + assertEquals(expectedMaxOffset, actualMaxOffset, 0); + } + + @Test + public void getMinExposureOffset() { + ExposureOffsetFeature mockExposureOffsetFeature = + mockCameraFeatureFactory.createExposureOffsetFeature(mockCameraProperties); + double expectedMinOffset = 21.5; + + when(mockExposureOffsetFeature.getMinExposureOffset()).thenReturn(21.5); + + double actualMinOffset = camera.getMinExposureOffset(); + + verify(mockExposureOffsetFeature, times(1)).getMinExposureOffset(); + assertEquals(expectedMinOffset, actualMinOffset, 0); + } + + @Test + public void getMaxZoomLevel() { + ZoomLevelFeature mockZoomLevelFeature = + mockCameraFeatureFactory.createZoomLevelFeature(mockCameraProperties); + float expectedMaxZoomLevel = 4.2f; + + when(mockZoomLevelFeature.getMaximumZoomLevel()).thenReturn(expectedMaxZoomLevel); + + float actualMaxZoomLevel = camera.getMaxZoomLevel(); + + verify(mockZoomLevelFeature, times(1)).getMaximumZoomLevel(); + assertEquals(expectedMaxZoomLevel, actualMaxZoomLevel, 0); + } + + @Test + public void getMinZoomLevel() { + ZoomLevelFeature mockZoomLevelFeature = + mockCameraFeatureFactory.createZoomLevelFeature(mockCameraProperties); + float expectedMinZoomLevel = 4.2f; + + when(mockZoomLevelFeature.getMinimumZoomLevel()).thenReturn(expectedMinZoomLevel); + + float actualMinZoomLevel = camera.getMinZoomLevel(); + + verify(mockZoomLevelFeature, times(1)).getMinimumZoomLevel(); + assertEquals(expectedMinZoomLevel, actualMinZoomLevel, 0); + } + + @Test + public void getRecordingProfile() { + ResolutionFeature mockResolutionFeature = + mockCameraFeatureFactory.createResolutionFeature(mockCameraProperties, null, null); + CamcorderProfile mockCamcorderProfile = mock(CamcorderProfile.class); + + when(mockResolutionFeature.getRecordingProfile()).thenReturn(mockCamcorderProfile); + + CamcorderProfile actualRecordingProfile = camera.getRecordingProfile(); + + verify(mockResolutionFeature, times(1)).getRecordingProfile(); + assertEquals(mockCamcorderProfile, actualRecordingProfile); + } + + @Test + public void setExposureMode_shouldUpdateExposureLockFeature() { + ExposureLockFeature mockExposureLockFeature = + mockCameraFeatureFactory.createExposureLockFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + ExposureMode exposureMode = ExposureMode.locked; + + camera.setExposureMode(mockResult, exposureMode); + + verify(mockExposureLockFeature, times(1)).setValue(exposureMode); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setExposureMode_shouldUpdateBuilder() { + ExposureLockFeature mockExposureLockFeature = + mockCameraFeatureFactory.createExposureLockFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + ExposureMode exposureMode = ExposureMode.locked; + + camera.setExposureMode(mockResult, exposureMode); + + verify(mockExposureLockFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setExposureMode_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + ExposureMode exposureMode = ExposureMode.locked; + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setExposureMode(mockResult, exposureMode); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)) + .error("setExposureModeFailed", "Could not set exposure mode.", null); + } + + @Test + public void setExposurePoint_shouldUpdateExposurePointFeature() { + SensorOrientationFeature mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + ExposurePointFeature mockExposurePointFeature = + mockCameraFeatureFactory.createExposurePointFeature( + mockCameraProperties, mockSensorOrientationFeature); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + Point point = new Point(42d, 42d); + + camera.setExposurePoint(mockResult, point); + + verify(mockExposurePointFeature, times(1)).setValue(point); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setExposurePoint_shouldUpdateBuilder() { + SensorOrientationFeature mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + ExposurePointFeature mockExposurePointFeature = + mockCameraFeatureFactory.createExposurePointFeature( + mockCameraProperties, mockSensorOrientationFeature); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + Point point = new Point(42d, 42d); + + camera.setExposurePoint(mockResult, point); + + verify(mockExposurePointFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setExposurePoint_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + Point point = new Point(42d, 42d); + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setExposurePoint(mockResult, point); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)) + .error("setExposurePointFailed", "Could not set exposure point.", null); + } + + @Test + public void setFlashMode_shouldUpdateFlashFeature() { + FlashFeature mockFlashFeature = + mockCameraFeatureFactory.createFlashFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + FlashMode flashMode = FlashMode.always; + + camera.setFlashMode(mockResult, flashMode); + + verify(mockFlashFeature, times(1)).setValue(flashMode); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setFlashMode_shouldUpdateBuilder() { + FlashFeature mockFlashFeature = + mockCameraFeatureFactory.createFlashFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + FlashMode flashMode = FlashMode.always; + + camera.setFlashMode(mockResult, flashMode); + + verify(mockFlashFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setFlashMode_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + FlashMode flashMode = FlashMode.always; + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setFlashMode(mockResult, flashMode); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)).error("setFlashModeFailed", "Could not set flash mode.", null); + } + + @Test + public void setFocusPoint_shouldUpdateFocusPointFeature() { + SensorOrientationFeature mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + FocusPointFeature mockFocusPointFeature = + mockCameraFeatureFactory.createFocusPointFeature( + mockCameraProperties, mockSensorOrientationFeature); + AutoFocusFeature mockAutoFocusFeature = + mockCameraFeatureFactory.createAutoFocusFeature(mockCameraProperties, false); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + Point point = new Point(42d, 42d); + when(mockAutoFocusFeature.getValue()).thenReturn(FocusMode.auto); + + camera.setFocusPoint(mockResult, point); + + verify(mockFocusPointFeature, times(1)).setValue(point); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setFocusPoint_shouldUpdateBuilder() { + SensorOrientationFeature mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + FocusPointFeature mockFocusPointFeature = + mockCameraFeatureFactory.createFocusPointFeature( + mockCameraProperties, mockSensorOrientationFeature); + AutoFocusFeature mockAutoFocusFeature = + mockCameraFeatureFactory.createAutoFocusFeature(mockCameraProperties, false); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + Point point = new Point(42d, 42d); + when(mockAutoFocusFeature.getValue()).thenReturn(FocusMode.auto); + + camera.setFocusPoint(mockResult, point); + + verify(mockFocusPointFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setFocusPoint_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + AutoFocusFeature mockAutoFocusFeature = + mockCameraFeatureFactory.createAutoFocusFeature(mockCameraProperties, false); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + Point point = new Point(42d, 42d); + when(mockAutoFocusFeature.getValue()).thenReturn(FocusMode.auto); + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setFocusPoint(mockResult, point); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)).error("setFocusPointFailed", "Could not set focus point.", null); + } + + @Test + public void setZoomLevel_shouldUpdateZoomLevelFeature() throws CameraAccessException { + ZoomLevelFeature mockZoomLevelFeature = + mockCameraFeatureFactory.createZoomLevelFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + float zoomLevel = 1.0f; + + when(mockZoomLevelFeature.getValue()).thenReturn(zoomLevel); + when(mockZoomLevelFeature.getMinimumZoomLevel()).thenReturn(0f); + when(mockZoomLevelFeature.getMaximumZoomLevel()).thenReturn(2f); + + camera.setZoomLevel(mockResult, zoomLevel); + + verify(mockZoomLevelFeature, times(1)).setValue(zoomLevel); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setZoomLevel_shouldUpdateBuilder() throws CameraAccessException { + ZoomLevelFeature mockZoomLevelFeature = + mockCameraFeatureFactory.createZoomLevelFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + float zoomLevel = 1.0f; + + when(mockZoomLevelFeature.getValue()).thenReturn(zoomLevel); + when(mockZoomLevelFeature.getMinimumZoomLevel()).thenReturn(0f); + when(mockZoomLevelFeature.getMaximumZoomLevel()).thenReturn(2f); + + camera.setZoomLevel(mockResult, zoomLevel); + + verify(mockZoomLevelFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setZoomLevel_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + ZoomLevelFeature mockZoomLevelFeature = + mockCameraFeatureFactory.createZoomLevelFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + float zoomLevel = 1.0f; + + when(mockZoomLevelFeature.getValue()).thenReturn(zoomLevel); + when(mockZoomLevelFeature.getMinimumZoomLevel()).thenReturn(0f); + when(mockZoomLevelFeature.getMaximumZoomLevel()).thenReturn(2f); + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setZoomLevel(mockResult, zoomLevel); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)).error("setZoomLevelFailed", "Could not set zoom level.", null); + } + + @Test + public void pauseVideoRecording_shouldSendNullResultWhenNotRecording() { + TestUtils.setPrivateField(camera, "recordingVideo", false); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.pauseVideoRecording(mockResult); + + verify(mockResult, times(1)).success(null); + verify(mockResult, never()).error(any(), any(), any()); + } + + @Test + public void pauseVideoRecording_shouldCallPauseWhenRecordingAndOnAPIN() { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + TestUtils.setPrivateField(camera, "mediaRecorder", mockMediaRecorder); + TestUtils.setPrivateField(camera, "recordingVideo", true); + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 24); + + camera.pauseVideoRecording(mockResult); + + verify(mockMediaRecorder, times(1)).pause(); + verify(mockResult, times(1)).success(null); + verify(mockResult, never()).error(any(), any(), any()); + } + + @Test + public void pauseVideoRecording_shouldSendVideoRecordingFailedErrorWhenVersionCodeSmallerThenN() { + TestUtils.setPrivateField(camera, "recordingVideo", true); + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 23); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.pauseVideoRecording(mockResult); + + verify(mockResult, times(1)) + .error("videoRecordingFailed", "pauseVideoRecording requires Android API +24.", null); + verify(mockResult, never()).success(any()); + } + + @Test + public void + pauseVideoRecording_shouldSendVideoRecordingFailedErrorWhenMediaRecorderPauseThrowsIllegalStateException() { + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + TestUtils.setPrivateField(camera, "mediaRecorder", mockMediaRecorder); + TestUtils.setPrivateField(camera, "recordingVideo", true); + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 24); + + IllegalStateException expectedException = new IllegalStateException("Test error message"); + + doThrow(expectedException).when(mockMediaRecorder).pause(); + + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.pauseVideoRecording(mockResult); + + verify(mockResult, times(1)).error("videoRecordingFailed", "Test error message", null); + verify(mockResult, never()).success(any()); + } + + @Test + public void resumeVideoRecording_shouldSendNullResultWhenNotRecording() { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + TestUtils.setPrivateField(camera, "recordingVideo", false); + + camera.resumeVideoRecording(mockResult); + + verify(mockResult, times(1)).success(null); + verify(mockResult, never()).error(any(), any(), any()); + } + + @Test + public void resumeVideoRecording_shouldCallPauseWhenRecordingAndOnAPIN() { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + TestUtils.setPrivateField(camera, "mediaRecorder", mockMediaRecorder); + TestUtils.setPrivateField(camera, "recordingVideo", true); + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 24); + + camera.resumeVideoRecording(mockResult); + + verify(mockMediaRecorder, times(1)).resume(); + verify(mockResult, times(1)).success(null); + verify(mockResult, never()).error(any(), any(), any()); + } + + @Test + public void + resumeVideoRecording_shouldSendVideoRecordingFailedErrorWhenVersionCodeSmallerThanN() { + TestUtils.setPrivateField(camera, "recordingVideo", true); + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 23); + + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.resumeVideoRecording(mockResult); + + verify(mockResult, times(1)) + .error("videoRecordingFailed", "resumeVideoRecording requires Android API +24.", null); + verify(mockResult, never()).success(any()); + } + + @Test + public void + resumeVideoRecording_shouldSendVideoRecordingFailedErrorWhenMediaRecorderPauseThrowsIllegalStateException() { + MediaRecorder mockMediaRecorder = mock(MediaRecorder.class); + TestUtils.setPrivateField(camera, "mediaRecorder", mockMediaRecorder); + TestUtils.setPrivateField(camera, "recordingVideo", true); + TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 24); + + IllegalStateException expectedException = new IllegalStateException("Test error message"); + + doThrow(expectedException).when(mockMediaRecorder).resume(); + + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.resumeVideoRecording(mockResult); + + verify(mockResult, times(1)).error("videoRecordingFailed", "Test error message", null); + verify(mockResult, never()).success(any()); + } + + @Test + public void setFocusMode_shouldUpdateAutoFocusFeature() { + AutoFocusFeature mockAutoFocusFeature = + mockCameraFeatureFactory.createAutoFocusFeature(mockCameraProperties, false); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.setFocusMode(mockResult, FocusMode.auto); + + verify(mockAutoFocusFeature, times(1)).setValue(FocusMode.auto); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setFocusMode_shouldUpdateBuilder() { + AutoFocusFeature mockAutoFocusFeature = + mockCameraFeatureFactory.createAutoFocusFeature(mockCameraProperties, false); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.setFocusMode(mockResult, FocusMode.auto); + + verify(mockAutoFocusFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setFocusMode_shouldUnlockAutoFocusForAutoMode() { + camera.setFocusMode(mock(MethodChannel.Result.class), FocusMode.auto); + verify(mockPreviewRequestBuilder, times(1)) + .set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL); + verify(mockPreviewRequestBuilder, times(1)) + .set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); + } + + @Test + public void setFocusMode_shouldSkipUnlockAutoFocusWhenNullCaptureSession() { + TestUtils.setPrivateField(camera, "captureSession", null); + camera.setFocusMode(mock(MethodChannel.Result.class), FocusMode.auto); + verify(mockPreviewRequestBuilder, never()) + .set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL); + verify(mockPreviewRequestBuilder, never()) + .set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); + } + + @Test + public void setFocusMode_shouldSendErrorEventOnUnlockAutoFocusCameraAccessException() + throws CameraAccessException { + when(mockCaptureSession.capture(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + camera.setFocusMode(mock(MethodChannel.Result.class), FocusMode.auto); + verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); + } + + @Test + public void setFocusMode_shouldLockAutoFocusForLockedMode() throws CameraAccessException { + camera.setFocusMode(mock(MethodChannel.Result.class), FocusMode.locked); + verify(mockPreviewRequestBuilder, times(1)) + .set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START); + verify(mockCaptureSession, times(1)).capture(any(), any(), any()); + verify(mockCaptureSession, times(1)).setRepeatingRequest(any(), any(), any()); + } + + @Test + public void setFocusMode_shouldSkipLockAutoFocusWhenNullCaptureSession() { + TestUtils.setPrivateField(camera, "captureSession", null); + camera.setFocusMode(mock(MethodChannel.Result.class), FocusMode.locked); + verify(mockPreviewRequestBuilder, never()) + .set(CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START); + } + + @Test + public void setFocusMode_shouldSendErrorEventOnLockAutoFocusCameraAccessException() + throws CameraAccessException { + when(mockCaptureSession.capture(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + camera.setFocusMode(mock(MethodChannel.Result.class), FocusMode.locked); + verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); + } + + @Test + public void setFocusMode_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setFocusMode(mockResult, FocusMode.locked); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)) + .error("setFocusModeFailed", "Error setting focus mode: null", null); + } + + @Test + public void setExposureOffset_shouldUpdateExposureOffsetFeature() { + ExposureOffsetFeature mockExposureOffsetFeature = + mockCameraFeatureFactory.createExposureOffsetFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.setExposureOffset(mockResult, 1.0); + + verify(mockExposureOffsetFeature, times(1)).setValue(1.0); + verify(mockResult, never()).error(any(), any(), any()); + verify(mockResult, times(1)).success(null); + } + + @Test + public void setExposureOffset_shouldAndUpdateBuilder() { + ExposureOffsetFeature mockExposureOffsetFeature = + mockCameraFeatureFactory.createExposureOffsetFeature(mockCameraProperties); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + camera.setExposureOffset(mockResult, 1.0); + + verify(mockExposureOffsetFeature, times(1)).updateBuilder(any()); + } + + @Test + public void setExposureOffset_shouldCallErrorOnResultOnCameraAccessException() + throws CameraAccessException { + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0, "")); + + camera.setExposureOffset(mockResult, 1.0); + + verify(mockResult, never()).success(any()); + verify(mockResult, times(1)) + .error("setExposureOffsetFailed", "Could not set exposure offset.", null); + } + + @Test + public void lockCaptureOrientation_shouldLockCaptureOrientation() { + final Activity mockActivity = mock(Activity.class); + SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature( + mockCameraProperties, mockActivity, mockDartMessenger); + + camera.lockCaptureOrientation(PlatformChannel.DeviceOrientation.PORTRAIT_UP); + + verify(mockSensorOrientationFeature, times(1)) + .lockCaptureOrientation(PlatformChannel.DeviceOrientation.PORTRAIT_UP); + } + + @Test + public void unlockCaptureOrientation_shouldUnlockCaptureOrientation() { + final Activity mockActivity = mock(Activity.class); + SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature( + mockCameraProperties, mockActivity, mockDartMessenger); + + camera.unlockCaptureOrientation(); + + verify(mockSensorOrientationFeature, times(1)).unlockCaptureOrientation(); + } + + private static class TestCameraFeatureFactory implements CameraFeatureFactory { + private final AutoFocusFeature mockAutoFocusFeature; + private final ExposureLockFeature mockExposureLockFeature; + private final ExposureOffsetFeature mockExposureOffsetFeature; + private final ExposurePointFeature mockExposurePointFeature; + private final FlashFeature mockFlashFeature; + private final FocusPointFeature mockFocusPointFeature; + private final FpsRangeFeature mockFpsRangeFeature; + private final NoiseReductionFeature mockNoiseReductionFeature; + private final ResolutionFeature mockResolutionFeature; + private final SensorOrientationFeature mockSensorOrientationFeature; + private final ZoomLevelFeature mockZoomLevelFeature; + + public TestCameraFeatureFactory() { + this.mockAutoFocusFeature = mock(AutoFocusFeature.class); + this.mockExposureLockFeature = mock(ExposureLockFeature.class); + this.mockExposureOffsetFeature = mock(ExposureOffsetFeature.class); + this.mockExposurePointFeature = mock(ExposurePointFeature.class); + this.mockFlashFeature = mock(FlashFeature.class); + this.mockFocusPointFeature = mock(FocusPointFeature.class); + this.mockFpsRangeFeature = mock(FpsRangeFeature.class); + this.mockNoiseReductionFeature = mock(NoiseReductionFeature.class); + this.mockResolutionFeature = mock(ResolutionFeature.class); + this.mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + this.mockZoomLevelFeature = mock(ZoomLevelFeature.class); + } + + @Override + public AutoFocusFeature createAutoFocusFeature( + @NonNull CameraProperties cameraProperties, boolean recordingVideo) { + return mockAutoFocusFeature; + } + + @Override + public ExposureLockFeature createExposureLockFeature( + @NonNull CameraProperties cameraProperties) { + return mockExposureLockFeature; + } + + @Override + public ExposureOffsetFeature createExposureOffsetFeature( + @NonNull CameraProperties cameraProperties) { + return mockExposureOffsetFeature; + } + + @Override + public FlashFeature createFlashFeature(@NonNull CameraProperties cameraProperties) { + return mockFlashFeature; + } + + @Override + public ResolutionFeature createResolutionFeature( + @NonNull CameraProperties cameraProperties, + ResolutionPreset initialSetting, + String cameraName) { + return mockResolutionFeature; + } + + @Override + public FocusPointFeature createFocusPointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrienttionFeature) { + return mockFocusPointFeature; + } + + @Override + public FpsRangeFeature createFpsRangeFeature(@NonNull CameraProperties cameraProperties) { + return mockFpsRangeFeature; + } + + @Override + public SensorOrientationFeature createSensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger) { + return mockSensorOrientationFeature; + } + + @Override + public ZoomLevelFeature createZoomLevelFeature(@NonNull CameraProperties cameraProperties) { + return mockZoomLevelFeature; + } + + @Override + public ExposurePointFeature createExposurePointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature) { + return mockExposurePointFeature; + } + + @Override + public NoiseReductionFeature createNoiseReductionFeature( + @NonNull CameraProperties cameraProperties) { + return mockNoiseReductionFeature; + } + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java index b97192b889cf..6b714ce41e34 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java @@ -12,7 +12,7 @@ public class CameraUtilsTest { @Test - public void serializeDeviceOrientation_serializes_correctly() { + public void serializeDeviceOrientation_serializesCorrectly() { assertEquals( "portraitUp", CameraUtils.serializeDeviceOrientation(PlatformChannel.DeviceOrientation.PORTRAIT_UP)); @@ -33,7 +33,7 @@ public void serializeDeviceOrientation_throws_for_null() { } @Test - public void deserializeDeviceOrientation_deserializes_correctly() { + public void deserializeDeviceOrientation_deserializesCorrectly() { assertEquals( PlatformChannel.DeviceOrientation.PORTRAIT_UP, CameraUtils.deserializeDeviceOrientation("portraitUp")); @@ -49,54 +49,7 @@ public void deserializeDeviceOrientation_deserializes_correctly() { } @Test(expected = UnsupportedOperationException.class) - public void deserializeDeviceOrientation_throws_for_null() { + public void deserializeDeviceOrientation_throwsForNull() { CameraUtils.deserializeDeviceOrientation(null); } - - @Test - public void getDeviceOrientationFromDegrees_converts_correctly() { - // Portrait UP - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_UP, - CameraUtils.getDeviceOrientationFromDegrees(0)); - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_UP, - CameraUtils.getDeviceOrientationFromDegrees(315)); - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_UP, - CameraUtils.getDeviceOrientationFromDegrees(44)); - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_UP, - CameraUtils.getDeviceOrientationFromDegrees(-45)); - // Portrait DOWN - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_DOWN, - CameraUtils.getDeviceOrientationFromDegrees(180)); - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_DOWN, - CameraUtils.getDeviceOrientationFromDegrees(135)); - assertEquals( - PlatformChannel.DeviceOrientation.PORTRAIT_DOWN, - CameraUtils.getDeviceOrientationFromDegrees(224)); - // Landscape LEFT - assertEquals( - PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT, - CameraUtils.getDeviceOrientationFromDegrees(90)); - assertEquals( - PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT, - CameraUtils.getDeviceOrientationFromDegrees(45)); - assertEquals( - PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT, - CameraUtils.getDeviceOrientationFromDegrees(134)); - // Landscape RIGHT - assertEquals( - PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT, - CameraUtils.getDeviceOrientationFromDegrees(270)); - assertEquals( - PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT, - CameraUtils.getDeviceOrientationFromDegrees(225)); - assertEquals( - PlatformChannel.DeviceOrientation.LANDSCAPE_RIGHT, - CameraUtils.getDeviceOrientationFromDegrees(314)); - } } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraZoomTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraZoomTest.java index 1385c2e36949..d3e495551608 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraZoomTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraZoomTest.java @@ -19,7 +19,7 @@ public class CameraZoomTest { @Test - public void ctor_when_parameters_are_valid() { + public void ctor_whenParametersAreValid() { final Rect sensorSize = new Rect(0, 0, 0, 0); final Float maxZoom = 4.0f; final CameraZoom cameraZoom = new CameraZoom(sensorSize, maxZoom); @@ -31,7 +31,7 @@ public void ctor_when_parameters_are_valid() { } @Test - public void ctor_when_sensor_size_is_null() { + public void ctor_whenSensorSizeIsNull() { final Rect sensorSize = null; final Float maxZoom = 4.0f; final CameraZoom cameraZoom = new CameraZoom(sensorSize, maxZoom); @@ -42,7 +42,7 @@ public void ctor_when_sensor_size_is_null() { } @Test - public void ctor_when_max_zoom_is_null() { + public void ctor_whenMaxZoomIsNull() { final Rect sensorSize = new Rect(0, 0, 0, 0); final Float maxZoom = null; final CameraZoom cameraZoom = new CameraZoom(sensorSize, maxZoom); @@ -53,7 +53,7 @@ public void ctor_when_max_zoom_is_null() { } @Test - public void ctor_when_max_zoom_is_smaller_then_default_zoom_factor() { + public void ctor_whenMaxZoomIsSmallerThenDefaultZoomFactor() { final Rect sensorSize = new Rect(0, 0, 0, 0); final Float maxZoom = 0.5f; final CameraZoom cameraZoom = new CameraZoom(sensorSize, maxZoom); @@ -64,7 +64,7 @@ public void ctor_when_max_zoom_is_smaller_then_default_zoom_factor() { } @Test - public void setZoom_when_no_support_should_not_set_scaler_crop_region() { + public void setZoom_whenNoSupportShouldNotSetScalerCropRegion() { final CameraZoom cameraZoom = new CameraZoom(null, null); final Rect computedZoom = cameraZoom.computeZoom(2f); @@ -72,7 +72,7 @@ public void setZoom_when_no_support_should_not_set_scaler_crop_region() { } @Test - public void setZoom_when_sensor_size_equals_zero_should_return_crop_region_of_zero() { + public void setZoom_whenSensorSizeEqualsZeroShouldReturnCropRegionOfZero() { final Rect sensorSize = new Rect(0, 0, 0, 0); final CameraZoom cameraZoom = new CameraZoom(sensorSize, 20f); final Rect computedZoom = cameraZoom.computeZoom(18f); @@ -85,7 +85,7 @@ public void setZoom_when_sensor_size_equals_zero_should_return_crop_region_of_ze } @Test - public void setZoom_when_sensor_size_is_valid_should_return_crop_region() { + public void setZoom_whenSensorSizeIsValidShouldReturnCropRegion() { final Rect sensorSize = new Rect(0, 0, 100, 100); final CameraZoom cameraZoom = new CameraZoom(sensorSize, 20f); final Rect computedZoom = cameraZoom.computeZoom(18f); @@ -98,7 +98,7 @@ public void setZoom_when_sensor_size_is_valid_should_return_crop_region() { } @Test - public void setZoom_when_zoom_is_greater_then_max_zoom_clamp_to_max_zoom() { + public void setZoom_whenZoomIsGreaterThenMaxZoomClampToMaxZoom() { final Rect sensorSize = new Rect(0, 0, 100, 100); final CameraZoom cameraZoom = new CameraZoom(sensorSize, 10f); final Rect computedZoom = cameraZoom.computeZoom(25f); @@ -111,7 +111,7 @@ public void setZoom_when_zoom_is_greater_then_max_zoom_clamp_to_max_zoom() { } @Test - public void setZoom_when_zoom_is_smaller_then_min_zoom_clamp_to_min_zoom() { + public void setZoom_whenZoomIsSmallerThenMinZoomClampToMinZoom() { final Rect sensorSize = new Rect(0, 0, 100, 100); final CameraZoom cameraZoom = new CameraZoom(sensorSize, 10f); final Rect computedZoom = cameraZoom.computeZoom(0.5f); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java index 25f5df9e9db9..0a2fc43d03cb 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java @@ -16,8 +16,8 @@ import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.StandardMethodCodec; -import io.flutter.plugins.camera.types.ExposureMode; -import io.flutter.plugins.camera.types.FocusMode; +import io.flutter.plugins.camera.features.autofocus.FocusMode; +import io.flutter.plugins.camera.features.exposurelock.ExposureMode; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java index d2c9f4498332..0358ce6cb785 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java @@ -80,7 +80,7 @@ public void teardown() { } @Test - public void run_writes_bytes_to_file_and_finishes_with_path() throws IOException { + public void runWritesBytesToFileAndFinishesWithPath() throws IOException { imageSaver.run(); verify(mockFileOutputStream, times(1)).write(new byte[] {0x42, 0x00, 0x13}); @@ -89,7 +89,7 @@ public void run_writes_bytes_to_file_and_finishes_with_path() throws IOException } @Test - public void run_calls_error_on_write_ioexception() throws IOException { + public void runCallsErrorOnWriteIoexception() throws IOException { doThrow(new IOException()).when(mockFileOutputStream).write(any()); imageSaver.run(); verify(mockCallback, times(1)).onError("IOError", "Failed saving image"); @@ -97,7 +97,7 @@ public void run_calls_error_on_write_ioexception() throws IOException { } @Test - public void run_calls_error_on_close_ioexception() throws IOException { + public void runCallsErrorOnCloseIoexception() throws IOException { doThrow(new IOException("message")).when(mockFileOutputStream).close(); imageSaver.run(); verify(mockCallback, times(1)).onError("cameraAccess", "message"); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/PictureCaptureRequestTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/PictureCaptureRequestTest.java deleted file mode 100644 index f257a7f7fd4b..000000000000 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/PictureCaptureRequestTest.java +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.camera; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import io.flutter.plugin.common.MethodChannel; -import org.junit.Test; - -public class PictureCaptureRequestTest { - - @Test - public void state_is_idle_by_default() { - PictureCaptureRequest req = new PictureCaptureRequest(null); - assertEquals("Default state is idle", req.getState(), PictureCaptureRequest.State.idle); - } - - @Test - public void setState_sets_state() { - PictureCaptureRequest req = new PictureCaptureRequest(null); - req.setState(PictureCaptureRequest.State.focusing); - assertEquals("State is focusing", req.getState(), PictureCaptureRequest.State.focusing); - req.setState(PictureCaptureRequest.State.preCapture); - assertEquals("State is preCapture", req.getState(), PictureCaptureRequest.State.preCapture); - req.setState(PictureCaptureRequest.State.waitingPreCaptureReady); - assertEquals( - "State is waitingPreCaptureReady", - req.getState(), - PictureCaptureRequest.State.waitingPreCaptureReady); - req.setState(PictureCaptureRequest.State.capturing); - assertEquals( - "State is awaitingPreCapture", req.getState(), PictureCaptureRequest.State.capturing); - } - - @Test - public void setState_resets_timeout() { - PictureCaptureRequest.TimeoutHandler mockTimeoutHandler = - mock(PictureCaptureRequest.TimeoutHandler.class); - PictureCaptureRequest req = new PictureCaptureRequest(null, mockTimeoutHandler); - req.setState(PictureCaptureRequest.State.focusing); - req.setState(PictureCaptureRequest.State.preCapture); - req.setState(PictureCaptureRequest.State.waitingPreCaptureReady); - req.setState(PictureCaptureRequest.State.capturing); - verify(mockTimeoutHandler, times(4)).resetTimeout(any()); - verify(mockTimeoutHandler, never()).clearTimeout(any()); - } - - @Test - public void setState_clears_timeout() { - PictureCaptureRequest.TimeoutHandler mockTimeoutHandler = - mock(PictureCaptureRequest.TimeoutHandler.class); - PictureCaptureRequest req = new PictureCaptureRequest(null, mockTimeoutHandler); - req.setState(PictureCaptureRequest.State.idle); - req.setState(PictureCaptureRequest.State.finished); - req = new PictureCaptureRequest(null, mockTimeoutHandler); - req.setState(PictureCaptureRequest.State.error); - verify(mockTimeoutHandler, never()).resetTimeout(any()); - verify(mockTimeoutHandler, times(3)).clearTimeout(any()); - } - - @Test - public void finish_sets_result_and_state() { - // Setup - MethodChannel.Result mockResult = mock(MethodChannel.Result.class); - PictureCaptureRequest req = new PictureCaptureRequest(mockResult); - // Act - req.finish("/test/path"); - // Test - verify(mockResult).success("/test/path"); - assertEquals("State is finished", req.getState(), PictureCaptureRequest.State.finished); - } - - @Test - public void finish_clears_timeout() { - PictureCaptureRequest.TimeoutHandler mockTimeoutHandler = - mock(PictureCaptureRequest.TimeoutHandler.class); - MethodChannel.Result mockResult = mock(MethodChannel.Result.class); - PictureCaptureRequest req = new PictureCaptureRequest(mockResult, mockTimeoutHandler); - req.finish("/test/path"); - verify(mockTimeoutHandler, never()).resetTimeout(any()); - verify(mockTimeoutHandler).clearTimeout(any()); - } - - @Test - public void isFinished_is_true_When_state_is_finished_or_error() { - // Setup - PictureCaptureRequest req = new PictureCaptureRequest(null); - // Test false states - req.setState(PictureCaptureRequest.State.idle); - assertFalse(req.isFinished()); - req.setState(PictureCaptureRequest.State.preCapture); - assertFalse(req.isFinished()); - req.setState(PictureCaptureRequest.State.capturing); - assertFalse(req.isFinished()); - // Test true states - req.setState(PictureCaptureRequest.State.finished); - assertTrue(req.isFinished()); - req = new PictureCaptureRequest(null); // Refresh - req.setState(PictureCaptureRequest.State.error); - assertTrue(req.isFinished()); - } - - @Test(expected = IllegalStateException.class) - public void finish_throws_When_already_finished() { - // Setup - PictureCaptureRequest req = new PictureCaptureRequest(null); - req.setState(PictureCaptureRequest.State.finished); - // Act - req.finish("/test/path"); - } - - @Test - public void error_sets_result_and_state() { - // Setup - MethodChannel.Result mockResult = mock(MethodChannel.Result.class); - PictureCaptureRequest req = new PictureCaptureRequest(mockResult); - // Act - req.error("ERROR_CODE", "Error Message", null); - // Test - verify(mockResult).error("ERROR_CODE", "Error Message", null); - assertEquals("State is error", req.getState(), PictureCaptureRequest.State.error); - } - - @Test - public void error_clears_timeout() { - PictureCaptureRequest.TimeoutHandler mockTimeoutHandler = - mock(PictureCaptureRequest.TimeoutHandler.class); - MethodChannel.Result mockResult = mock(MethodChannel.Result.class); - PictureCaptureRequest req = new PictureCaptureRequest(mockResult, mockTimeoutHandler); - req.error("ERROR_CODE", "Error Message", null); - verify(mockTimeoutHandler, never()).resetTimeout(any()); - verify(mockTimeoutHandler).clearTimeout(any()); - } - - @Test(expected = IllegalStateException.class) - public void error_throws_When_already_finished() { - // Setup - PictureCaptureRequest req = new PictureCaptureRequest(null); - req.setState(PictureCaptureRequest.State.finished); - // Act - req.error(null, null, null); - } -} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/AutoFocusFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/AutoFocusFeatureTest.java index 84e4ad0d0e91..fd8ef7c766a2 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/AutoFocusFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/AutoFocusFeatureTest.java @@ -28,7 +28,7 @@ public class AutoFocusFeatureTest { }; @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -36,7 +36,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_auto_if_not_set() { + public void getValue_shouldReturnAutoIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -44,7 +44,7 @@ public void getValue_should_return_auto_if_not_set() { } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); FocusMode expectedValue = FocusMode.locked; @@ -56,7 +56,7 @@ public void getValue_should_echo_the_set_value() { } @Test - public void checkIsSupported_should_return_false_when_minimum_focus_distance_is_zero() { + public void checkIsSupported_shouldReturnFalseWhenMinimumFocusDistanceIsZero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -67,7 +67,7 @@ public void checkIsSupported_should_return_false_when_minimum_focus_distance_is_ } @Test - public void checkIsSupported_should_return_false_when_minimum_focus_distance_is_null() { + public void checkIsSupported_shouldReturnFalseWhenMinimumFocusDistanceIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -78,7 +78,7 @@ public void checkIsSupported_should_return_false_when_minimum_focus_distance_is_ } @Test - public void checkIsSupport_should_return_false_when_no_focus_modes_are_available() { + public void checkIsSupport_shouldReturnFalseWhenNoFocusModesAreAvailable() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -89,7 +89,7 @@ public void checkIsSupport_should_return_false_when_no_focus_modes_are_available } @Test - public void checkIsSupport_should_return_false_when_only_focus_off_is_available() { + public void checkIsSupport_shouldReturnFalseWhenOnlyFocusOffIsAvailable() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -100,7 +100,7 @@ public void checkIsSupport_should_return_false_when_only_focus_off_is_available( } @Test - public void checkIsSupport_should_return_true_when_only_multiple_focus_modes_are_available() { + public void checkIsSupport_shouldReturnTrueWhenOnlyMultipleFocusModesAreAvailable() { CameraProperties mockCameraProperties = mock(CameraProperties.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -111,7 +111,7 @@ public void checkIsSupport_should_return_true_when_only_multiple_focus_modes_are } @Test - public void updateBuilder_should_return_when_checkIsSupported_is_false() { + public void updateBuilderShouldReturnWhenCheckIsSupportedIsFalse() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -125,7 +125,7 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void updateBuilder_should_set_control_mode_to_auto_when_focus_is_locked() { + public void updateBuilder_shouldSetControlModeToAutoWhenFocusIsLocked() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); @@ -142,7 +142,7 @@ public void updateBuilder_should_set_control_mode_to_auto_when_focus_is_locked() @Test public void - updateBuilder_should_set_control_mode_to_continuous_video_when_focus_is_auto_and_recording_video() { + updateBuilder_shouldSetControlModeToContinuousVideoWhenFocusIsAutoAndRecordingVideo() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, true); @@ -159,7 +159,7 @@ public void updateBuilder_should_set_control_mode_to_auto_when_focus_is_locked() @Test public void - updateBuilder_should_set_control_mode_to_continuous_video_when_focus_is_auto_and_not_recording_video() { + updateBuilder_shouldSetControlModeToContinuousVideoWhenFocusIsAutoAndNotRecordingVideo() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); AutoFocusFeature autoFocusFeature = new AutoFocusFeature(mockCameraProperties, false); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/FocusModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/FocusModeTest.java index 70d52d458d4d..f68ae7140601 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/FocusModeTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/autofocus/FocusModeTest.java @@ -11,7 +11,7 @@ public class FocusModeTest { @Test - public void getValueForString_returns_correct_values() { + public void getValueForString_returnsCorrectValues() { assertEquals( "Returns FocusMode.auto for 'auto'", FocusMode.getValueForString("auto"), FocusMode.auto); assertEquals( @@ -21,13 +21,13 @@ public void getValueForString_returns_correct_values() { } @Test - public void getValueForString_returns_null_for_nonexistant_value() { + public void getValueForString_returnsNullForNonexistantValue() { assertEquals( "Returns null for 'nonexistant'", FocusMode.getValueForString("nonexistant"), null); } @Test - public void toString_returns_correct_value() { + public void toString_returnsCorrectValue() { assertEquals("Returns 'auto' for FocusMode.auto", FocusMode.auto.toString(), "auto"); assertEquals("Returns 'locked' for FocusMode.locked", FocusMode.locked.toString(), "locked"); } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureLockFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureLockFeatureTest.java index d9e0a8d69c96..1cda0a86d575 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureLockFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureLockFeatureTest.java @@ -16,7 +16,7 @@ public class ExposureLockFeatureTest { @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureLockFeature exposureLockFeature = new ExposureLockFeature(mockCameraProperties); @@ -24,7 +24,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_auto_if_not_set() { + public void getValue_shouldReturnAutoIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureLockFeature exposureLockFeature = new ExposureLockFeature(mockCameraProperties); @@ -32,7 +32,7 @@ public void getValue_should_return_auto_if_not_set() { } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureLockFeature exposureLockFeature = new ExposureLockFeature(mockCameraProperties); ExposureMode expectedValue = ExposureMode.locked; @@ -44,7 +44,7 @@ public void getValue_should_echo_the_set_value() { } @Test - public void checkIsSupported_should_return_true() { + public void checkIsSupported_shouldReturnTrue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureLockFeature exposureLockFeature = new ExposureLockFeature(mockCameraProperties); @@ -52,8 +52,7 @@ public void checkIsSupported_should_return_true() { } @Test - public void - updateBuilder_should_set_control_ae_lock_to_false_when_auto_exposure_is_set_to_auto() { + public void updateBuilder_shouldSetControlAeLockToFalseWhenAutoExposureIsSetToAuto() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); ExposureLockFeature exposureLockFeature = new ExposureLockFeature(mockCameraProperties); @@ -65,8 +64,7 @@ public void checkIsSupported_should_return_true() { } @Test - public void - updateBuilder_should_set_control_ae_lock_to_false_when_auto_exposure_is_set_to_locked() { + public void updateBuilder_shouldSetControlAeLockToFalseWhenAutoExposureIsSetToLocked() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); ExposureLockFeature exposureLockFeature = new ExposureLockFeature(mockCameraProperties); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureModeTest.java index ad1d3d98f295..d5d47697776c 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureModeTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurelock/ExposureModeTest.java @@ -11,7 +11,7 @@ public class ExposureModeTest { @Test - public void getValueForString_returns_correct_values() { + public void getValueForString_returnsCorrectValues() { assertEquals( "Returns ExposureMode.auto for 'auto'", ExposureMode.getValueForString("auto"), @@ -23,13 +23,13 @@ public void getValueForString_returns_correct_values() { } @Test - public void getValueForString_returns_null_for_nonexistant_value() { + public void getValueForString_returnsNullForNonexistantValue() { assertEquals( "Returns null for 'nonexistant'", ExposureMode.getValueForString("nonexistant"), null); } @Test - public void toString_returns_correct_value() { + public void toString_returnsCorrectValue() { assertEquals("Returns 'auto' for ExposureMode.auto", ExposureMode.auto.toString(), "auto"); assertEquals( "Returns 'locked' for ExposureMode.locked", ExposureMode.locked.toString(), "locked"); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposureoffset/ExposureOffsetFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposureoffset/ExposureOffsetFeatureTest.java index 40d17fdc496e..ee428f3d5e02 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposureoffset/ExposureOffsetFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposureoffset/ExposureOffsetFeatureTest.java @@ -17,7 +17,7 @@ public class ExposureOffsetFeatureTest { @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureOffsetFeature exposureOffsetFeature = new ExposureOffsetFeature(mockCameraProperties); @@ -25,7 +25,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_zero_if_not_set() { + public void getValue_shouldReturnZeroIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureOffsetFeature exposureOffsetFeature = new ExposureOffsetFeature(mockCameraProperties); @@ -35,7 +35,7 @@ public void getValue_should_return_zero_if_not_set() { } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureOffsetFeature exposureOffsetFeature = new ExposureOffsetFeature(mockCameraProperties); double expectedValue = 4.0; @@ -49,8 +49,7 @@ public void getValue_should_echo_the_set_value() { } @Test - public void - getExposureOffsetStepSize_should_return_the_control_exposure_compensation_step_value() { + public void getExposureOffsetStepSize_shouldReturnTheControlExposureCompensationStepValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureOffsetFeature exposureOffsetFeature = new ExposureOffsetFeature(mockCameraProperties); @@ -60,7 +59,7 @@ public void getValue_should_echo_the_set_value() { } @Test - public void checkIsSupported_should_return_true() { + public void checkIsSupported_shouldReturnTrue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ExposureOffsetFeature exposureOffsetFeature = new ExposureOffsetFeature(mockCameraProperties); @@ -68,7 +67,7 @@ public void checkIsSupported_should_return_true() { } @Test - public void updateBuilder_should_set_control_ae_exposure_compensation_to_offset() { + public void updateBuilder_shouldSetControlAeExposureCompensationToOffset() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); ExposureOffsetFeature exposureOffsetFeature = new ExposureOffsetFeature(mockCameraProperties); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java index 4a515c6fd0ec..b34a04fe26b7 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/exposurepoint/ExposurePointFeatureTest.java @@ -19,9 +19,12 @@ import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.MeteringRectangle; import android.util.Size; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugins.camera.CameraProperties; import io.flutter.plugins.camera.CameraRegionUtils; import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.sensororientation.DeviceOrientationManager; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; import org.junit.Before; import org.junit.Test; import org.mockito.MockedStatic; @@ -30,35 +33,44 @@ public class ExposurePointFeatureTest { Size mockCameraBoundaries; + SensorOrientationFeature mockSensorOrientationFeature; + DeviceOrientationManager mockDeviceOrientationManager; @Before public void setUp() { this.mockCameraBoundaries = mock(Size.class); when(this.mockCameraBoundaries.getWidth()).thenReturn(100); when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + when(mockDeviceOrientationManager.getLastUIOrientation()) + .thenReturn(PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegionUtils mockCameraRegions = mock(CameraRegionUtils.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); assertEquals("ExposurePointFeature", exposurePointFeature.getDebugName()); } @Test - public void getValue_should_return_null_if_not_set() { + public void getValue_shouldReturnNullIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); - Point actualPoint = exposurePointFeature.getValue(); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); assertNull(exposurePointFeature.getValue()); } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); Point expectedPoint = new Point(0.0, 0.0); @@ -69,9 +81,10 @@ public void getValue_should_echo_the_set_value() { } @Test - public void setValue_should_reset_point_when_x_coord_is_null() { + public void setValue_shouldResetPointWhenXCoordIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); exposurePointFeature.setValue(new Point(null, 0.0)); @@ -80,9 +93,10 @@ public void setValue_should_reset_point_when_x_coord_is_null() { } @Test - public void setValue_should_reset_point_when_y_coord_is_null() { + public void setValue_shouldResetPointWhenYCoordIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); exposurePointFeature.setValue(new Point(0.0, null)); @@ -91,9 +105,10 @@ public void setValue_should_reset_point_when_y_coord_is_null() { } @Test - public void setValue_should_set_point_when_valid_coords_are_supplied() { + public void setValue_shouldSetPointWhenValidCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); Point point = new Point(0.0, 0.0); @@ -103,11 +118,11 @@ public void setValue_should_set_point_when_valid_coords_are_supplied() { } @Test - public void - setValue_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + public void setValue_shouldDetermineMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); Size mockedCameraBoundaries = mock(Size.class); exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); @@ -117,16 +132,22 @@ public void setValue_should_set_point_when_valid_coords_are_supplied() { exposurePointFeature.setValue(new Point(0.5, 0.5)); mockedCameraRegionUtils.verify( - () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT), times(1)); } } @Test(expected = AssertionError.class) - public void setValue_should_throw_assertion_error_when_no_valid_boundaries_are_set() { + public void setValue_shouldThrowAssertionErrorWhenNoValidBoundariesAreSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); try (MockedStatic mockedCameraRegionUtils = Mockito.mockStatic(CameraRegionUtils.class)) { @@ -135,10 +156,11 @@ public void setValue_should_throw_assertion_error_when_no_valid_boundaries_are_s } @Test - public void setValue_should_not_determine_metering_rectangle_when_null_coords_are_set() { + public void setValue_shouldNotDetermineMeteringRectangleWhenNullCoordsAreSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); Size mockedCameraBoundaries = mock(Size.class); exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); @@ -155,10 +177,11 @@ public void setValue_should_not_determine_metering_rectangle_when_null_coords_ar @Test public void - setCameraBoundaries_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + setCameraBoundaries_shouldDetermineMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); exposurePointFeature.setValue(new Point(0.5, 0.5)); Size mockedCameraBoundaries = mock(Size.class); @@ -169,15 +192,21 @@ public void setValue_should_not_determine_metering_rectangle_when_null_coords_ar exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); mockedCameraRegionUtils.verify( - () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT), times(1)); } } @Test - public void checkIsSupported_should_return_false_when_max_regions_is_null() { + public void checkIsSupported_shouldReturnFalseWhenMaxRegionsIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(null); @@ -186,9 +215,10 @@ public void checkIsSupported_should_return_false_when_max_regions_is_null() { } @Test - public void checkIsSupported_should_return_false_when_max_regions_is_zero() { + public void checkIsSupported_shouldReturnFalseWhenMaxRegionsIsZero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(0); @@ -197,9 +227,10 @@ public void checkIsSupported_should_return_false_when_max_regions_is_zero() { } @Test - public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_zero() { + public void checkIsSupported_shouldReturnTrueWhenMaxRegionsIsBiggerThenZero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); @@ -208,10 +239,11 @@ public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_ } @Test - public void updateBuilder_should_return_when_checkIsSupported_is_false() { + public void updateBuilder_shouldReturnWhenCheckIsSupportedIsFalse() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(0); @@ -221,12 +253,12 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void - updateBuilder_should_set_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + public void updateBuilder_shouldSetMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); Size mockedCameraBoundaries = mock(Size.class); MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); @@ -236,7 +268,10 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { .when( () -> CameraRegionUtils.convertPointToMeteringRectangle( - mockedCameraBoundaries, 0.5, 0.5)) + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT)) .thenReturn(mockedMeteringRectangle); exposurePointFeature.setCameraBoundaries(mockedCameraBoundaries); exposurePointFeature.setValue(new Point(0.5, 0.5)); @@ -249,13 +284,12 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void - updateBuilder_should_not_set_metering_rectangle_when_no_valid_boundaries_are_supplied() { + public void updateBuilder_shouldNotSetMeteringRectangleWhenNoValidBoundariesAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); - MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.updateBuilder(mockCaptureRequestBuilder); @@ -263,11 +297,12 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void updateBuilder_should_not_set_metering_rectangle_when_no_valid_coords_are_supplied() { + public void updateBuilder_shouldNotSetMeteringRectangleWhenNoValidCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoExposure()).thenReturn(1); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - ExposurePointFeature exposurePointFeature = new ExposurePointFeature(mockCameraProperties); + ExposurePointFeature exposurePointFeature = + new ExposurePointFeature(mockCameraProperties, mockSensorOrientationFeature); exposurePointFeature.setCameraBoundaries(this.mockCameraBoundaries); exposurePointFeature.setValue(null); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java index eccfb07993c1..f2b4ffc8197c 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/flash/FlashFeatureTest.java @@ -20,7 +20,7 @@ public class FlashFeatureTest { @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -28,7 +28,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_auto_if_not_set() { + public void getValue_shouldReturnAutoIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -36,7 +36,7 @@ public void getValue_should_return_auto_if_not_set() { } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); FlashMode expectedValue = FlashMode.torch; @@ -48,7 +48,7 @@ public void getValue_should_echo_the_set_value() { } @Test - public void checkIsSupported_should_return_false_when_flash_info_available_is_null() { + public void checkIsSupported_shouldReturnFalseWhenFlashInfoAvailableIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -58,7 +58,7 @@ public void checkIsSupported_should_return_false_when_flash_info_available_is_nu } @Test - public void checkIsSupported_should_return_false_when_flash_info_available_is_false() { + public void checkIsSupported_shouldReturnFalseWhenFlashInfoAvailableIsFalse() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -68,7 +68,7 @@ public void checkIsSupported_should_return_false_when_flash_info_available_is_fa } @Test - public void checkIsSupported_should_return_true_when_flash_info_available_is_true() { + public void checkIsSupported_shouldReturnTrueWhenFlashInfoAvailableIsTrue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -78,7 +78,7 @@ public void checkIsSupported_should_return_true_when_flash_info_available_is_tru } @Test - public void updateBuilder_should_return_when_checkIsSupported_is_false() { + public void updateBuilder_shouldReturnWhenCheckIsSupportedIsFalse() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -91,7 +91,7 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_off() { + public void updateBuilder_shouldSetAeModeAndFlashModeWhenFlashModeIsOff() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -107,7 +107,7 @@ public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_o } @Test - public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_always() { + public void updateBuilder_shouldSetAeModeAndFlashModeWhenFlashModeIsAlways() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -123,7 +123,7 @@ public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_a } @Test - public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_torch() { + public void updateBuilder_shouldSetAeModeAndFlashModeWhenFlashModeIsTorch() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); @@ -139,7 +139,7 @@ public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_t } @Test - public void updateBuilder_should_set_ae_mode_and_flash_mode_when_flash_mode_is_auto() { + public void updateBuilder_shouldSetAeModeAndFlashModeWhenFlashModeIsAuto() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); FlashFeature flashFeature = new FlashFeature(mockCameraProperties); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java index d158336ef235..f03dc9f62e87 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java @@ -19,9 +19,12 @@ import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.MeteringRectangle; import android.util.Size; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugins.camera.CameraProperties; import io.flutter.plugins.camera.CameraRegionUtils; import io.flutter.plugins.camera.features.Point; +import io.flutter.plugins.camera.features.sensororientation.DeviceOrientationManager; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; import org.junit.Before; import org.junit.Test; import org.mockito.MockedStatic; @@ -30,35 +33,45 @@ public class FocusPointFeatureTest { Size mockCameraBoundaries; + SensorOrientationFeature mockSensorOrientationFeature; + DeviceOrientationManager mockDeviceOrientationManager; @Before public void setUp() { this.mockCameraBoundaries = mock(Size.class); when(this.mockCameraBoundaries.getWidth()).thenReturn(100); when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + mockSensorOrientationFeature = mock(SensorOrientationFeature.class); + mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + when(mockDeviceOrientationManager.getLastUIOrientation()) + .thenReturn(PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT); } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - CameraRegionUtils mockCameraRegions = mock(CameraRegionUtils.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); assertEquals("FocusPointFeature", focusPointFeature.getDebugName()); } @Test - public void getValue_should_return_null_if_not_set() { + public void getValue_shouldReturnNullIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); Point actualPoint = focusPointFeature.getValue(); assertNull(focusPointFeature.getValue()); } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); Point expectedPoint = new Point(0.0, 0.0); @@ -69,9 +82,10 @@ public void getValue_should_echo_the_set_value() { } @Test - public void setValue_should_reset_point_when_x_coord_is_null() { + public void setValue_shouldResetPointWhenXCoordIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); focusPointFeature.setValue(new Point(null, 0.0)); @@ -80,9 +94,10 @@ public void setValue_should_reset_point_when_x_coord_is_null() { } @Test - public void setValue_should_reset_point_when_y_coord_is_null() { + public void setValue_shouldResetPointWhenYCoordIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); focusPointFeature.setValue(new Point(0.0, null)); @@ -91,9 +106,10 @@ public void setValue_should_reset_point_when_y_coord_is_null() { } @Test - public void setValue_should_set_point_when_valid_coords_are_supplied() { + public void setValue_shouldSetPointWhenValidCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); Point point = new Point(0.0, 0.0); @@ -103,11 +119,11 @@ public void setValue_should_set_point_when_valid_coords_are_supplied() { } @Test - public void - setValue_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + public void setValue_shouldDetermineMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); Size mockedCameraBoundaries = mock(Size.class); focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); @@ -117,16 +133,22 @@ public void setValue_should_set_point_when_valid_coords_are_supplied() { focusPointFeature.setValue(new Point(0.5, 0.5)); mockedCameraRegionUtils.verify( - () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT), times(1)); } } @Test(expected = AssertionError.class) - public void setValue_should_throw_assertion_error_when_no_valid_boundaries_are_set() { + public void setValue_shouldThrowAssertionErrorWhenNoValidBoundariesAreSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); try (MockedStatic mockedCameraRegionUtils = Mockito.mockStatic(CameraRegionUtils.class)) { @@ -135,10 +157,11 @@ public void setValue_should_throw_assertion_error_when_no_valid_boundaries_are_s } @Test - public void setValue_should_not_determine_metering_rectangle_when_null_coords_are_set() { + public void setValue_shouldNotDetermineMeteringRectangleWhenNullCoordsAreSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); Size mockedCameraBoundaries = mock(Size.class); focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); @@ -155,10 +178,11 @@ public void setValue_should_not_determine_metering_rectangle_when_null_coords_ar @Test public void - setCameraBoundaries_should_determine_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + setCameraBoundaries_shouldDetermineMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); focusPointFeature.setValue(new Point(0.5, 0.5)); Size mockedCameraBoundaries = mock(Size.class); @@ -169,15 +193,21 @@ public void setValue_should_not_determine_metering_rectangle_when_null_coords_ar focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); mockedCameraRegionUtils.verify( - () -> CameraRegionUtils.convertPointToMeteringRectangle(mockedCameraBoundaries, 0.5, 0.5), + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT), times(1)); } } @Test - public void checkIsSupported_should_return_false_when_max_regions_is_null() { + public void checkIsSupported_shouldReturnFalseWhenMaxRegionsIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(null); @@ -186,9 +216,10 @@ public void checkIsSupported_should_return_false_when_max_regions_is_null() { } @Test - public void checkIsSupported_should_return_false_when_max_regions_is_zero() { + public void checkIsSupported_shouldReturnFalseWhenMaxRegionsIsZero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(0); @@ -197,9 +228,10 @@ public void checkIsSupported_should_return_false_when_max_regions_is_zero() { } @Test - public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_zero() { + public void checkIsSupported_shouldReturnTrueWhenMaxRegionsIsBiggerThenZero() { CameraProperties mockCameraProperties = mock(CameraProperties.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(new Size(100, 100)); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); @@ -208,10 +240,11 @@ public void checkIsSupported_should_return_true_when_max_regions_is_bigger_then_ } @Test - public void updateBuilder_should_return_when_checkIsSupported_is_false() { + public void updateBuilder_shouldReturnWhenCheckIsSupportedIsFalse() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(0); @@ -221,12 +254,12 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void - updateBuilder_should_set_metering_rectangle_when_valid_boundaries_and_coords_are_supplied() { + public void updateBuilder_shouldSetMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); Size mockedCameraBoundaries = mock(Size.class); MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); @@ -236,7 +269,10 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { .when( () -> CameraRegionUtils.convertPointToMeteringRectangle( - mockedCameraBoundaries, 0.5, 0.5)) + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT)) .thenReturn(mockedMeteringRectangle); focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); focusPointFeature.setValue(new Point(0.5, 0.5)); @@ -249,12 +285,12 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void - updateBuilder_should_not_set_metering_rectangle_when_no_valid_boundaries_are_supplied() { + public void updateBuilder_shouldNotSetMeteringRectangleWhenNoValidBoundariesAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); focusPointFeature.updateBuilder(mockCaptureRequestBuilder); @@ -263,11 +299,12 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void updateBuilder_should_not_set_metering_rectangle_when_no_valid_coords_are_supplied() { + public void updateBuilder_shouldNotSetMeteringRectangleWhenNoValidCoordsAreSupplied() { CameraProperties mockCameraProperties = mock(CameraProperties.class); when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); - FocusPointFeature focusPointFeature = new FocusPointFeature(mockCameraProperties); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); focusPointFeature.setValue(null); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java index 7b6e70fff5b2..93cfe5523df3 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java @@ -18,7 +18,7 @@ @RunWith(RobolectricTestRunner.class) public class FpsRangeFeaturePixel4aTest { @Test - public void ctor_should_initialize_fps_range_with_30_when_device_is_pixel_4a() { + public void ctor_shouldInitializeFpsRangeWith30WhenDeviceIsPixel4a() { TestUtils.setFinalStatic(Build.class, "BRAND", "google"); TestUtils.setFinalStatic(Build.class, "MODEL", "Pixel 4a"); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java index 77937b5e87c6..2bb4d849a277 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java @@ -35,19 +35,19 @@ public void after() { } @Test - public void ctor_should_initialize_fps_range_with_highest_upper_value_from_range_array() { + public void ctor_shouldInitializeFpsRangeWithHighestUpperValueFromRangeArray() { FpsRangeFeature fpsRangeFeature = createTestInstance(); assertEquals(13, (int) fpsRangeFeature.getValue().getUpper()); } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { FpsRangeFeature fpsRangeFeature = createTestInstance(); assertEquals("FpsRangeFeature", fpsRangeFeature.getDebugName()); } @Test - public void getValue_should_return_highest_upper_range_if_not_set() { + public void getValue_shouldReturnHighestUpperRangeIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FpsRangeFeature fpsRangeFeature = createTestInstance(); @@ -55,7 +55,7 @@ public void getValue_should_return_highest_upper_range_if_not_set() { } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mockCameraProperties); @SuppressWarnings("unchecked") @@ -68,14 +68,14 @@ public void getValue_should_echo_the_set_value() { } @Test - public void checkIsSupported_should_return_true() { + public void checkIsSupported_shouldReturnTrue() { FpsRangeFeature fpsRangeFeature = createTestInstance(); assertTrue(fpsRangeFeature.checkIsSupported()); } @Test @SuppressWarnings("unchecked") - public void updateBuilder_should_set_ae_target_fps_range() { + public void updateBuilder_shouldSetAeTargetFpsRange() { CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); FpsRangeFeature fpsRangeFeature = createTestInstance(); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java index eb1a639a2ac3..b89aad0f6773 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java @@ -37,7 +37,7 @@ public void after() { } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); @@ -45,7 +45,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_fast_if_not_set() { + public void getValue_shouldReturnFastIfNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); @@ -53,7 +53,7 @@ public void getValue_should_return_fast_if_not_set() { } @Test - public void getValue_should_echo_the_set_value() { + public void getValue_shouldEchoTheSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); NoiseReductionMode expectedValue = NoiseReductionMode.fast; @@ -65,7 +65,7 @@ public void getValue_should_echo_the_set_value() { } @Test - public void checkIsSupported_should_return_false_when_available_noise_reduction_modes_is_null() { + public void checkIsSupported_shouldReturnFalseWhenAvailableNoiseReductionModesIsNull() { CameraProperties mockCameraProperties = mock(CameraProperties.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); @@ -76,7 +76,7 @@ public void checkIsSupported_should_return_false_when_available_noise_reduction_ @Test public void - checkIsSupported_should_return_false_when_available_noise_reduction_modes_returns_an_empty_array() { + checkIsSupported_shouldReturnFalseWhenAvailableNoiseReductionModesReturnsAnEmptyArray() { CameraProperties mockCameraProperties = mock(CameraProperties.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); @@ -87,7 +87,7 @@ public void checkIsSupported_should_return_false_when_available_noise_reduction_ @Test public void - checkIsSupported_should_return_true_when_available_noise_reduction_modes_returns_at_least_one_item() { + checkIsSupported_shouldReturnTrueWhenAvailableNoiseReductionModesReturnsAtLeastOneItem() { CameraProperties mockCameraProperties = mock(CameraProperties.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); @@ -97,7 +97,7 @@ public void checkIsSupported_should_return_false_when_available_noise_reduction_ } @Test - public void updateBuilder_should_return_when_checkIsSupported_is_false() { + public void updateBuilder_shouldReturnWhenCheckIsSupportedIsFalse() { CameraProperties mockCameraProperties = mock(CameraProperties.class); CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); @@ -110,29 +110,28 @@ public void updateBuilder_should_return_when_checkIsSupported_is_false() { } @Test - public void updateBuilder_should_set_noise_reduction_mode_off_when_off() { + public void updateBuilder_shouldSetNoiseReductionModeOffWhenOff() { testUpdateBuilderWith(NoiseReductionMode.off, CaptureRequest.NOISE_REDUCTION_MODE_OFF); } @Test - public void updateBuilder_should_set_noise_reduction_mode_fast_when_fast() { + public void updateBuilder_shouldSetNoiseReductionModeFastWhenFast() { testUpdateBuilderWith(NoiseReductionMode.fast, CaptureRequest.NOISE_REDUCTION_MODE_FAST); } @Test - public void updateBuilder_should_set_noise_reduction_mode_high_quality_when_high_quality() { + public void updateBuilder_shouldSetNoiseReductionModeHighQualityWhenHighQuality() { testUpdateBuilderWith( NoiseReductionMode.highQuality, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY); } @Test - public void updateBuilder_should_set_noise_reduction_mode_minimal_when_minimal() { + public void updateBuilder_shouldSetNoiseReductionModeMinimalWhenMinimal() { testUpdateBuilderWith(NoiseReductionMode.minimal, CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL); } @Test - public void - updateBuilder_should_set_noise_reduction_mode_zero_shutter_lag_when_zero_shutter_lag() { + public void updateBuilder_shouldSetNoiseReductionModeZeroShutterLagWhenZeroShutterLag() { testUpdateBuilderWith( NoiseReductionMode.zeroShutterLag, CaptureRequest.NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG); } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java index bb9cb61e1508..e09223dfabe9 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java @@ -79,7 +79,7 @@ public void after() { } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ResolutionFeature resolutionFeature = new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); @@ -88,7 +88,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_initial_value_when_not_set() { + public void getValue_shouldReturnInitialValueWhenNotSet() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ResolutionFeature resolutionFeature = new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); @@ -97,7 +97,7 @@ public void getValue_should_return_initial_value_when_not_set() { } @Test - public void getValue_should_echo_setValue() { + public void getValue_shouldEchoSetValue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ResolutionFeature resolutionFeature = new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); @@ -108,7 +108,7 @@ public void getValue_should_echo_setValue() { } @Test - public void checkIsSupport_returns_true() { + public void checkIsSupport_returnsTrue() { CameraProperties mockCameraProperties = mock(CameraProperties.class); ResolutionFeature resolutionFeature = new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); @@ -117,7 +117,7 @@ public void checkIsSupport_returns_true() { } @Test - public void getBestAvailableCamcorderProfileForResolutionPreset_should_fall_through() { + public void getBestAvailableCamcorderProfileForResolutionPreset_shouldFallThrough() { mockedStaticProfile .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_HIGH)) .thenReturn(false); @@ -147,42 +147,42 @@ public void getBestAvailableCamcorderProfileForResolutionPreset_should_fall_thro } @Test - public void computeBestPreviewSize_should_use_720P_when_resolution_preset_max() { + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetMax() { ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.max); mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); } @Test - public void computeBestPreviewSize_should_use_720P_when_resolution_preset_ultraHigh() { + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetUltraHigh() { ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.ultraHigh); mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); } @Test - public void computeBestPreviewSize_should_use_720P_when_resolution_preset_veryHigh() { + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetVeryHigh() { ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.veryHigh); mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); } @Test - public void computeBestPreviewSize_should_use_720P_when_resolution_preset_high() { + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetHigh() { ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.high); mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); } @Test - public void computeBestPreviewSize_should_use_480P_when_resolution_preset_medium() { + public void computeBestPreviewSize_shouldUse480PWhenResolutionPresetMedium() { ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.medium); mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_480P)); } @Test - public void computeBestPreviewSize_should_use_QVGA_when_resolution_preset_low() { + public void computeBestPreviewSize_shouldUseQVGAWhenResolutionPresetLow() { ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.low); mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_QVGA)); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java index 6e8d04d20e99..58f17cb758bf 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java @@ -50,15 +50,15 @@ public void before() { } @Test - public void getMediaOrientation_when_natural_screen_orientation_equals_portrait_up() { + public void getVideoOrientation_whenNaturalScreenOrientationEqualsPortraitUp() { int degreesPortraitUp = - deviceOrientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_UP); + deviceOrientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_UP); int degreesPortraitDown = - deviceOrientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_DOWN); + deviceOrientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_DOWN); int degreesLandscapeLeft = - deviceOrientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_LEFT); + deviceOrientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_LEFT); int degreesLandscapeRight = - deviceOrientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_RIGHT); + deviceOrientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_RIGHT); assertEquals(0, degreesPortraitUp); assertEquals(90, degreesLandscapeLeft); @@ -67,17 +67,17 @@ public void getMediaOrientation_when_natural_screen_orientation_equals_portrait_ } @Test - public void getMediaOrientation_when_natural_screen_orientation_equals_landscape_left() { + public void getVideoOrientation_whenNaturalScreenOrientationEqualsLandscapeLeft() { DeviceOrientationManager orientationManager = DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 90); - int degreesPortraitUp = orientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitUp = orientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_UP); int degreesPortraitDown = - orientationManager.getMediaOrientation(DeviceOrientation.PORTRAIT_DOWN); + orientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_DOWN); int degreesLandscapeLeft = - orientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_LEFT); + orientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_LEFT); int degreesLandscapeRight = - orientationManager.getMediaOrientation(DeviceOrientation.LANDSCAPE_RIGHT); + orientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_RIGHT); assertEquals(90, degreesPortraitUp); assertEquals(180, degreesLandscapeLeft); @@ -86,105 +86,96 @@ public void getMediaOrientation_when_natural_screen_orientation_equals_landscape } @Test - public void getMediaOrientation_should_fallback_to_sensor_orientation_when_orientation_is_null() { + public void getVideoOrientation_shouldFallbackToSensorOrientationWhenOrientationIsNull() { setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); - int degrees = deviceOrientationManager.getMediaOrientation(null); + int degrees = deviceOrientationManager.getVideoOrientation(null); assertEquals(90, degrees); } @Test - public void handleSensorOrientationChange_should_send_message_when_sensor_access_is_allowed() { - try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { - mockedSystem - .when( - () -> - Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) - .thenReturn(1); - setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); - - deviceOrientationManager.handleSensorOrientationChange(90); - } + public void getPhotoOrientation_whenNaturalScreenOrientationEqualsPortraitUp() { + int degreesPortraitUp = + deviceOrientationManager.getPhotoOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitDown = + deviceOrientationManager.getPhotoOrientation(DeviceOrientation.PORTRAIT_DOWN); + int degreesLandscapeLeft = + deviceOrientationManager.getPhotoOrientation(DeviceOrientation.LANDSCAPE_LEFT); + int degreesLandscapeRight = + deviceOrientationManager.getPhotoOrientation(DeviceOrientation.LANDSCAPE_RIGHT); - verify(mockDartMessenger, times(1)) - .sendDeviceOrientationChangeEvent(DeviceOrientation.LANDSCAPE_LEFT); + assertEquals(0, degreesPortraitUp); + assertEquals(90, degreesLandscapeRight); + assertEquals(180, degreesPortraitDown); + assertEquals(270, degreesLandscapeLeft); } @Test - public void - handleSensorOrientationChange_should_send_message_when_sensor_access_is_not_allowed() { - try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { - mockedSystem - .when( - () -> - Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) - .thenReturn(0); - setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + public void getPhotoOrientation_whenNaturalScreenOrientationEqualsLandscapeLeft() { + DeviceOrientationManager orientationManager = + DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 90); - deviceOrientationManager.handleSensorOrientationChange(90); - } + int degreesPortraitUp = orientationManager.getPhotoOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitDown = + orientationManager.getPhotoOrientation(DeviceOrientation.PORTRAIT_DOWN); + int degreesLandscapeLeft = + orientationManager.getPhotoOrientation(DeviceOrientation.LANDSCAPE_LEFT); + int degreesLandscapeRight = + orientationManager.getPhotoOrientation(DeviceOrientation.LANDSCAPE_RIGHT); - verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); + assertEquals(90, degreesPortraitUp); + assertEquals(180, degreesLandscapeRight); + assertEquals(270, degreesPortraitDown); + assertEquals(0, degreesLandscapeLeft); } @Test - public void handleUIOrientationChange_should_send_message_when_sensor_access_is_allowed() { - try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { - mockedSystem - .when( - () -> - Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) - .thenReturn(0); - setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + public void getPhotoOrientation_shouldFallbackToCurrentOrientationWhenOrientationIsNull() { + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); - deviceOrientationManager.handleUIOrientationChange(); - } + int degrees = deviceOrientationManager.getPhotoOrientation(null); - verify(mockDartMessenger, times(1)) - .sendDeviceOrientationChangeEvent(DeviceOrientation.LANDSCAPE_LEFT); + assertEquals(270, degrees); } @Test - public void handleUIOrientationChange_should_send_message_when_sensor_access_is_not_allowed() { + public void handleUIOrientationChange_shouldSendMessageWhenSensorAccessIsAllowed() { try (MockedStatic mockedSystem = mockStatic(Settings.System.class)) { mockedSystem .when( () -> Settings.System.getInt(any(), eq(Settings.System.ACCELEROMETER_ROTATION), eq(0))) - .thenReturn(1); + .thenReturn(0); setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); deviceOrientationManager.handleUIOrientationChange(); } - verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); + verify(mockDartMessenger, times(1)) + .sendDeviceOrientationChangeEvent(DeviceOrientation.LANDSCAPE_LEFT); } @Test - public void handleOrientationChange_should_send_message_when_orientation_is_updated() { + public void handleOrientationChange_shouldSendMessageWhenOrientationIsUpdated() { DeviceOrientation previousOrientation = DeviceOrientation.PORTRAIT_UP; DeviceOrientation newOrientation = DeviceOrientation.LANDSCAPE_LEFT; - DeviceOrientation orientation = - DeviceOrientationManager.handleOrientationChange( - newOrientation, previousOrientation, mockDartMessenger); + DeviceOrientationManager.handleOrientationChange( + newOrientation, previousOrientation, mockDartMessenger); verify(mockDartMessenger, times(1)).sendDeviceOrientationChangeEvent(newOrientation); - assertEquals(newOrientation, orientation); } @Test - public void handleOrientationChange_should_not_send_message_when_orientation_is_not_updated() { + public void handleOrientationChange_shouldNotSendMessageWhenOrientationIsNotUpdated() { DeviceOrientation previousOrientation = DeviceOrientation.PORTRAIT_UP; DeviceOrientation newOrientation = DeviceOrientation.PORTRAIT_UP; - DeviceOrientation orientation = - DeviceOrientationManager.handleOrientationChange( - newOrientation, previousOrientation, mockDartMessenger); + DeviceOrientationManager.handleOrientationChange( + newOrientation, previousOrientation, mockDartMessenger); verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); - assertEquals(newOrientation, orientation); } @Test diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java index ce2bb7bb2670..2c3a5ab46634 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java @@ -52,7 +52,7 @@ public void after() { } @Test - public void ctor_should_start_device_orientation_manager() { + public void ctor_shouldStartDeviceOrientationManager() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -60,7 +60,7 @@ public void ctor_should_start_device_orientation_manager() { } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -68,7 +68,7 @@ public void getDebugName_should_return_the_name_of_the_feature() { } @Test - public void getValue_should_return_null_if_not_set() { + public void getValue_shouldReturnNullIfNotSet() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -76,7 +76,7 @@ public void getValue_should_return_null_if_not_set() { } @Test - public void getValue_should_echo_setValue() { + public void getValue_shouldEchoSetValue() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -86,7 +86,7 @@ public void getValue_should_echo_setValue() { } @Test - public void checkIsSupport_returns_true() { + public void checkIsSupport_returnsTrue() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -94,8 +94,7 @@ public void checkIsSupport_returns_true() { } @Test - public void - getDeviceOrientationManager_should_return_initialized_DartOrientationManager_instance() { + public void getDeviceOrientationManager_shouldReturnInitializedDartOrientationManagerInstance() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -104,7 +103,7 @@ public void checkIsSupport_returns_true() { } @Test - public void lockCaptureOrientation_should_lock_to_specified_orientation() { + public void lockCaptureOrientation_shouldLockToSpecifiedOrientation() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); @@ -115,7 +114,7 @@ public void lockCaptureOrientation_should_lock_to_specified_orientation() { } @Test - public void unlockCaptureOrientation_should_set_lock_to_null() { + public void unlockCaptureOrientation_shouldSetLockToNull() { SensorOrientationFeature sensorOrientationFeature = new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java index c76708a3769e..9f05cc255a8b 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomLevelFeatureTest.java @@ -50,7 +50,7 @@ public void after() { } @Test - public void ctor_when_parameters_are_valid() { + public void ctor_whenParametersAreValid() { when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray); when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f); @@ -63,7 +63,7 @@ public void ctor_when_parameters_are_valid() { } @Test - public void ctor_when_sensor_size_is_null() { + public void ctor_whenSensorSizeIsNull() { when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(null); when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f); @@ -77,7 +77,7 @@ public void ctor_when_sensor_size_is_null() { } @Test - public void ctor_when_max_zoom_is_null() { + public void ctor_whenMaxZoomIsNull() { when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray); when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(null); @@ -91,7 +91,7 @@ public void ctor_when_max_zoom_is_null() { } @Test - public void ctor_when_max_zoom_is_smaller_then_default_zoom_factor() { + public void ctor_whenMaxZoomIsSmallerThenDefaultZoomFactor() { when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray); when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(0.5f); @@ -105,21 +105,21 @@ public void ctor_when_max_zoom_is_smaller_then_default_zoom_factor() { } @Test - public void getDebugName_should_return_the_name_of_the_feature() { + public void getDebugName_shouldReturnTheNameOfTheFeature() { ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties); assertEquals("ZoomLevelFeature", zoomLevelFeature.getDebugName()); } @Test - public void getValue_should_return_null_if_not_set() { + public void getValue_shouldReturnNullIfNotSet() { ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties); assertEquals(1.0, (float) zoomLevelFeature.getValue(), 0); } @Test - public void getValue_should_echo_setValue() { + public void getValue_shouldEchoSetValue() { ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties); zoomLevelFeature.setValue(2.3f); @@ -128,14 +128,14 @@ public void getValue_should_echo_setValue() { } @Test - public void checkIsSupport_returns_false_by_default() { + public void checkIsSupport_returnsFalseByDefault() { ZoomLevelFeature zoomLevelFeature = new ZoomLevelFeature(mockCameraProperties); assertFalse(zoomLevelFeature.checkIsSupported()); } @Test - public void updateBuilder_should_set_scalar_crop_region_when_checkIsSupport_is_true() { + public void updateBuilder_shouldSetScalarCropRegionWhenCheckIsSupportIsTrue() { when(mockCameraProperties.getSensorInfoActiveArraySize()).thenReturn(mockSensorArray); when(mockCameraProperties.getScalerAvailableMaxDigitalZoom()).thenReturn(42f); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java index f83e5fb11e08..28160ff30714 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/zoomlevel/ZoomUtilsTest.java @@ -15,7 +15,7 @@ @RunWith(RobolectricTestRunner.class) public class ZoomUtilsTest { @Test - public void setZoom_when_sensor_size_equals_zero_should_return_crop_region_of_zero() { + public void setZoom_whenSensorSizeEqualsZeroShouldReturnCropRegionOfZero() { final Rect sensorSize = new Rect(0, 0, 0, 0); final Rect computedZoom = ZoomUtils.computeZoom(18f, sensorSize, 1f, 20f); @@ -27,7 +27,7 @@ public void setZoom_when_sensor_size_equals_zero_should_return_crop_region_of_ze } @Test - public void setZoom_when_sensor_size_is_valid_should_return_crop_region() { + public void setZoom_whenSensorSizeIsValidShouldReturnCropRegion() { final Rect sensorSize = new Rect(0, 0, 100, 100); final Rect computedZoom = ZoomUtils.computeZoom(18f, sensorSize, 1f, 20f); @@ -39,7 +39,7 @@ public void setZoom_when_sensor_size_is_valid_should_return_crop_region() { } @Test - public void setZoom_when_zoom_is_greater_then_max_zoom_clamp_to_max_zoom() { + public void setZoom_whenZoomIsGreaterThenMaxZoomClampToMaxZoom() { final Rect sensorSize = new Rect(0, 0, 100, 100); final Rect computedZoom = ZoomUtils.computeZoom(25f, sensorSize, 1f, 10f); @@ -51,7 +51,7 @@ public void setZoom_when_zoom_is_greater_then_max_zoom_clamp_to_max_zoom() { } @Test - public void setZoom_when_zoom_is_smaller_then_min_zoom_clamp_to_min_zoom() { + public void setZoom_whenZoomIsSmallerThenMinZoomClampToMinZoom() { final Rect sensorSize = new Rect(0, 0, 100, 100); final Rect computedZoom = ZoomUtils.computeZoom(0.5f, sensorSize, 1f, 10f); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java index 9b8b54cc959c..5425409c2f3a 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java @@ -24,7 +24,7 @@ public void ctor_test() { } @Test - public void build_Should_set_values_in_correct_order_When_audio_is_disabled() throws IOException { + public void build_shouldSetValuesInCorrectOrderWhenAudioIsDisabled() throws IOException { CamcorderProfile recorderProfile = getEmptyCamcorderProfile(); MediaRecorderBuilder.MediaRecorderFactory mockFactory = mock(MediaRecorderBuilder.MediaRecorderFactory.class); @@ -55,7 +55,7 @@ public void build_Should_set_values_in_correct_order_When_audio_is_disabled() th } @Test - public void build_Should_set_values_in_correct_order_When_audio_is_enabled() throws IOException { + public void build_shouldSetValuesInCorrectOrderWhenAudioIsEnabled() throws IOException { CamcorderProfile recorderProfile = getEmptyCamcorderProfile(); MediaRecorderBuilder.MediaRecorderFactory mockFactory = mock(MediaRecorderBuilder.MediaRecorderFactory.class); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java index 5f4bd9f89ec7..dbef8510e021 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/ExposureModeTest.java @@ -11,7 +11,7 @@ public class ExposureModeTest { @Test - public void getValueForString_returns_correct_values() { + public void getValueForString_returnsCorrectValues() { assertEquals( "Returns ExposureMode.auto for 'auto'", ExposureMode.getValueForString("auto"), @@ -23,13 +23,13 @@ public void getValueForString_returns_correct_values() { } @Test - public void getValueForString_returns_null_for_nonexistant_value() { + public void getValueForString_returnsNullForNonexistantValue() { assertEquals( "Returns null for 'nonexistant'", ExposureMode.getValueForString("nonexistant"), null); } @Test - public void toString_returns_correct_value() { + public void toString_returnsCorrectValue() { assertEquals("Returns 'auto' for ExposureMode.auto", ExposureMode.auto.toString(), "auto"); assertEquals( "Returns 'locked' for ExposureMode.locked", ExposureMode.locked.toString(), "locked"); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java index 5a53648bc51e..7ae175ee4649 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FlashModeTest.java @@ -11,7 +11,7 @@ public class FlashModeTest { @Test - public void getValueForString_returns_correct_values() { + public void getValueForString_returnsCorrectValues() { assertEquals( "Returns FlashMode.off for 'off'", FlashMode.getValueForString("off"), FlashMode.off); assertEquals( @@ -27,13 +27,13 @@ public void getValueForString_returns_correct_values() { } @Test - public void getValueForString_returns_null_for_nonexistant_value() { + public void getValueForString_returnsNullForNonexistantValue() { assertEquals( "Returns null for 'nonexistant'", FlashMode.getValueForString("nonexistant"), null); } @Test - public void toString_returns_correct_value() { + public void toString_returnsCorrectValue() { assertEquals("Returns 'off' for FlashMode.off", FlashMode.off.toString(), "off"); assertEquals("Returns 'auto' for FlashMode.auto", FlashMode.auto.toString(), "auto"); assertEquals("Returns 'always' for FlashMode.always", FlashMode.always.toString(), "always"); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FocusModeTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FocusModeTest.java index 58e6d7ce3306..1d7b95c1b548 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FocusModeTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/types/FocusModeTest.java @@ -11,7 +11,7 @@ public class FocusModeTest { @Test - public void getValueForString_returns_correct_values() { + public void getValueForString_returnsCorrectValues() { assertEquals( "Returns FocusMode.auto for 'auto'", FocusMode.getValueForString("auto"), FocusMode.auto); assertEquals( @@ -21,13 +21,13 @@ public void getValueForString_returns_correct_values() { } @Test - public void getValueForString_returns_null_for_nonexistant_value() { + public void getValueForString_returnsNullForNonexistantValue() { assertEquals( "Returns null for 'nonexistant'", FocusMode.getValueForString("nonexistant"), null); } @Test - public void toString_returns_correct_value() { + public void toString_returnsCorrectValue() { assertEquals("Returns 'auto' for FocusMode.auto", FocusMode.auto.toString(), "auto"); assertEquals("Returns 'locked' for FocusMode.locked", FocusMode.locked.toString(), "locked"); } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java index 9fc669527bfa..dbf9d11be8b6 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java @@ -23,4 +23,14 @@ public static void setFinalStatic(Class classToModify, String fieldName, Assert.fail("Unable to mock static field: " + fieldName); } } + + public static void setPrivateField(T instance, String fieldName, Object newValue) { + try { + Field field = instance.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(instance, newValue); + } catch (Exception e) { + Assert.fail("Unable to mock private field: " + fieldName); + } + } } diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index 3284a9b01fa2..37869fe78528 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -118,7 +118,7 @@ class CameraValue { /// Whether setting the focus point is supported. final bool focusPointSupported; - /// The current device orientation. + /// The current device UI orientation. final DeviceOrientation deviceOrientation; /// The currently locked capture orientation. diff --git a/packages/camera/camera/lib/src/camera_preview.dart b/packages/camera/camera/lib/src/camera_preview.dart index ad3175a320a9..1df9f8e2e393 100644 --- a/packages/camera/camera/lib/src/camera_preview.dart +++ b/packages/camera/camera/lib/src/camera_preview.dart @@ -61,9 +61,9 @@ class CameraPreview extends StatelessWidget { int _getQuarterTurns() { Map turns = { DeviceOrientation.portraitUp: 0, - DeviceOrientation.landscapeLeft: 1, + DeviceOrientation.landscapeRight: 1, DeviceOrientation.portraitDown: 2, - DeviceOrientation.landscapeRight: 3, + DeviceOrientation.landscapeLeft: 3, }; return turns[_getApplicableOrientation()]!; } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 57161656fc03..a7c6a61a4ef2 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.8.1+7 +version: 0.9.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -25,6 +25,7 @@ dependencies: sdk: flutter pedantic: ^1.10.0 quiver: ^3.0.0 + flutter_plugin_android_lifecycle: ^2.0.2 dev_dependencies: flutter_test: diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index d579341c0e58..8275461192b4 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -146,7 +146,7 @@ void main() { RotatedBox rotatedBox = tester.widget(find.byType(RotatedBox)); - expect(rotatedBox.quarterTurns, 1); + expect(rotatedBox.quarterTurns, 3); debugDefaultTargetPlatformOverride = null; }); @@ -179,7 +179,7 @@ void main() { RotatedBox rotatedBox = tester.widget(find.byType(RotatedBox)); - expect(rotatedBox.quarterTurns, 3); + expect(rotatedBox.quarterTurns, 1); debugDefaultTargetPlatformOverride = null; }); diff --git a/packages/camera/camera_platform_interface/lib/src/events/device_event.dart b/packages/camera/camera_platform_interface/lib/src/events/device_event.dart index c6cedd135fed..ac1c66e4df82 100644 --- a/packages/camera/camera_platform_interface/lib/src/events/device_event.dart +++ b/packages/camera/camera_platform_interface/lib/src/events/device_event.dart @@ -20,8 +20,7 @@ import 'package:flutter/services.dart'; /// They can be (and in fact, are) filtered by the `instanceof`-operator. abstract class DeviceEvent {} -/// The [DeviceOrientationChangedEvent] is fired every time the user changes the -/// physical orientation of the device. +/// The [DeviceOrientationChangedEvent] is fired every time the orientation of the device UI changes. class DeviceOrientationChangedEvent extends DeviceEvent { /// The new orientation of the device final DeviceOrientation orientation; diff --git a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart index 9e84e8fdf47c..7a7bbf3da592 100644 --- a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart +++ b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart @@ -96,11 +96,10 @@ abstract class CameraPlatform extends PlatformInterface { throw UnimplementedError('onCameraTimeLimitReached() is not implemented.'); } - /// The device orientation changed. + /// The ui orientation changed. /// /// Implementations for this: /// - Should support all 4 orientations. - /// - Should not emit new values when the screen orientation is locked. Stream onDeviceOrientationChanged() { throw UnimplementedError( 'onDeviceOrientationChanged() is not implemented.'); From 97f61147c983f7ff4613d9dfecfb0a15d6ff67ed Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 24 Aug 2021 14:42:21 -0400 Subject: [PATCH 214/364] [flutter_plugin_tools] Improve process mocking (#4254) The mock process runner used in most of the tests had poor handling of stdout/stderr: - By default it would return the `List` output from the mock process, which was never correct since the parent process runner interface always sets encodings, thus the `dynamic` should always be of type `String` - The process for setting output on a `MockProcess` was awkward, since it was based on a `Stream>`, even though in every case what we actually want to do is just set the full output to a string. - A hack was at some point added (presumably due to the above issues) to bypass that flow at the process runner level, and instead return a `String` set there. That meant there were two ways of setting output (one of which that only worked for one of the ways of running processes) - That hack wasn't updated when the ability to return multiple mock processes instead of a single global mock process was added, so the API was even more confusing, and there was no way to set different output for different processes. This changes the test APIs so that: - `MockProcess` takes stdout and stderr as strings, and internally manages converting them to a `Stream>`. - `RecordingProcessRunner` correctly decodes and returns the output streams when constructing a process result. It also removes the resultStdout and resultStderr hacks, as well as the legacy `processToReturn` API, and converts all uses to the new structure, which is both simpler to use, and clearly associates output with specific processes. --- script/tool/test/analyze_command_test.dart | 4 +- .../test/build_examples_command_test.dart | 2 +- script/tool/test/common/gradle_test.dart | 2 +- script/tool/test/common/xcode_test.dart | 40 +++++--- .../test/drive_examples_command_test.dart | 10 +- .../test/firebase_test_lab_command_test.dart | 22 ++--- script/tool/test/format_command_test.dart | 30 +++--- .../tool/test/lint_android_command_test.dart | 2 +- .../tool/test/lint_podspecs_command_test.dart | 14 ++- script/tool/test/mocks.dart | 43 +++++--- .../tool/test/native_test_command_test.dart | 60 ++++++++---- .../tool/test/publish_check_command_test.dart | 98 ++++++++----------- .../test/publish_plugin_command_test.dart | 19 ++-- script/tool/test/test_command_test.dart | 10 +- script/tool/test/util.dart | 28 +++--- .../tool/test/xcode_analyze_command_test.dart | 4 +- 16 files changed, 197 insertions(+), 191 deletions(-) diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index da2f0aba86c8..502fa9a0634c 100644 --- a/script/tool/test/analyze_command_test.dart +++ b/script/tool/test/analyze_command_test.dart @@ -211,7 +211,7 @@ void main() { createFakePlugin('foo', packagesDir); processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() // flutter packages get + MockProcess(exitCode: 1) // flutter packages get ]; Error? commandError; @@ -233,7 +233,7 @@ void main() { createFakePlugin('foo', packagesDir); processRunner.mockProcessesForExecutable['dart'] = [ - MockProcess.failing() // dart analyze + MockProcess(exitCode: 1) // dart analyze ]; Error? commandError; diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 27489a50228a..9c7291c31ddb 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -63,7 +63,7 @@ void main() { processRunner .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [ - MockProcess.failing() // flutter packages get + MockProcess(exitCode: 1) // flutter packages get ]; Error? commandError; diff --git a/script/tool/test/common/gradle_test.dart b/script/tool/test/common/gradle_test.dart index c24887d3d469..3eac60baf3c3 100644 --- a/script/tool/test/common/gradle_test.dart +++ b/script/tool/test/common/gradle_test.dart @@ -168,7 +168,7 @@ void main() { processRunner.mockProcessesForExecutable[project.gradleWrapper.path] = [ - MockProcess.failing(), + MockProcess(exitCode: 1), ]; final int exitCode = await project.runCommand('foo'); diff --git a/script/tool/test/common/xcode_test.dart b/script/tool/test/common/xcode_test.dart index 7e046a2446c2..259d8ea36cd2 100644 --- a/script/tool/test/common/xcode_test.dart +++ b/script/tool/test/common/xcode_test.dart @@ -94,8 +94,9 @@ void main() { } }; - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = jsonEncode(devices); + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(devices)), + ]; expect(await xcode.findBestAvailableIphoneSimulator(), expectedDeviceId); }); @@ -137,15 +138,16 @@ void main() { } }; - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = jsonEncode(devices); + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(devices)), + ]; expect(await xcode.findBestAvailableIphoneSimulator(), null); }); test('returns null if simctl fails', () async { processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing(), + MockProcess(exitCode: 1), ]; expect(await xcode.findBestAvailableIphoneSimulator(), null); @@ -216,7 +218,7 @@ void main() { test('returns error codes', () async { processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing(), + MockProcess(exitCode: 1), ]; final Directory directory = const LocalFileSystem().currentDirectory; @@ -247,8 +249,7 @@ void main() { group('projectHasTarget', () { test('returns true when present', () async { - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = ''' + const String stdout = ''' { "project" : { "configurations" : [ @@ -266,6 +267,9 @@ void main() { ] } }'''; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: stdout), + ]; final Directory project = const LocalFileSystem().directory('/foo.xcodeproj'); @@ -287,8 +291,7 @@ void main() { }); test('returns false when not present', () async { - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = ''' + const String stdout = ''' { "project" : { "configurations" : [ @@ -305,6 +308,9 @@ void main() { ] } }'''; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: stdout), + ]; final Directory project = const LocalFileSystem().directory('/foo.xcodeproj'); @@ -326,8 +332,9 @@ void main() { }); test('returns null for unexpected output', () async { - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = '{}'; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: '{}'), + ]; final Directory project = const LocalFileSystem().directory('/foo.xcodeproj'); @@ -349,8 +356,9 @@ void main() { }); test('returns null for invalid output', () async { - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = ':)'; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: ':)'), + ]; final Directory project = const LocalFileSystem().directory('/foo.xcodeproj'); @@ -372,7 +380,9 @@ void main() { }); test('returns null for failure', () async { - processRunner.processToReturn = MockProcess.failing(); + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1), // xcodebuild -list + ]; final Directory project = const LocalFileSystem().directory('/foo.xcodeproj'); diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index c6893181e286..bbf865d3edf2 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -60,12 +60,10 @@ void main() { final String output = '''${includeBanner ? updateBanner : ''}[${devices.join(',')}]'''; - final MockProcess mockDevicesProcess = MockProcess.succeeding(); - mockDevicesProcess.stdoutController.close(); // ignore: unawaited_futures + final MockProcess mockDevicesProcess = MockProcess(stdout: output); processRunner .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [mockDevicesProcess]; - processRunner.resultStdout = output; } test('fails if no platforms are provided', () async { @@ -151,7 +149,7 @@ void main() { // Simulate failure from `flutter devices`. processRunner .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = - [MockProcess.failing()]; + [MockProcess(exitCode: 1)]; Error? commandError; final List output = await runCapturingPrint( @@ -954,8 +952,8 @@ void main() { .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [ // No mock for 'devices', since it's running for macOS. - MockProcess.failing(), // 'drive' #1 - MockProcess.failing(), // 'drive' #2 + MockProcess(exitCode: 1), // 'drive' #1 + MockProcess(exitCode: 1), // 'drive' #2 ]; Error? commandError; diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index 35697af3f5fd..7716990b323c 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -40,7 +40,7 @@ void main() { test('fails if gcloud auth fails', () async { processRunner.mockProcessesForExecutable['gcloud'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', @@ -64,8 +64,8 @@ void main() { test('retries gcloud set', () async { processRunner.mockProcessesForExecutable['gcloud'] = [ - MockProcess.succeeding(), // auth - MockProcess.failing(), // config + MockProcess(), // auth + MockProcess(exitCode: 1), // config ]; createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/integration_test/foo_test.dart', @@ -245,10 +245,10 @@ void main() { ]); processRunner.mockProcessesForExecutable['gcloud'] = [ - MockProcess.succeeding(), // auth - MockProcess.succeeding(), // config - MockProcess.failing(), // integration test #1 - MockProcess.succeeding(), // integration test #2 + MockProcess(), // auth + MockProcess(), // config + MockProcess(exitCode: 1), // integration test #1 + MockProcess(), // integration test #2 ]; Error? commandError; @@ -459,7 +459,7 @@ void main() { ]); processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() // flutter build + MockProcess(exitCode: 1) // flutter build ]; Error? commandError; @@ -496,7 +496,7 @@ void main() { .childFile('gradlew') .path; processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; @@ -533,8 +533,8 @@ void main() { .childFile('gradlew') .path; processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.succeeding(), // assembleAndroidTest - MockProcess.failing(), // assembleDebug + MockProcess(), // assembleAndroidTest + MockProcess(exitCode: 1), // assembleDebug ]; Error? commandError; diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index b072e5d30aaf..cf57a9d0dcf7 100644 --- a/script/tool/test/format_command_test.dart +++ b/script/tool/test/format_command_test.dart @@ -115,7 +115,7 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = - [MockProcess.failing()]; + [MockProcess(exitCode: 1)]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { @@ -167,7 +167,7 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['java'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; final List output = await runCapturingPrint( @@ -193,8 +193,8 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['java'] = [ - MockProcess.succeeding(), // check for working java - MockProcess.failing(), // format + MockProcess(), // check for working java + MockProcess(exitCode: 1), // format ]; Error? commandError; final List output = await runCapturingPrint( @@ -280,7 +280,7 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['clang-format'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; final List output = await runCapturingPrint( @@ -335,8 +335,8 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['clang-format'] = [ - MockProcess.succeeding(), // check for working clang-format - MockProcess.failing(), // format + MockProcess(), // check for working clang-format + MockProcess(exitCode: 1), // format ]; Error? commandError; final List output = await runCapturingPrint( @@ -418,11 +418,11 @@ void main() { ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); + const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; processRunner.mockProcessesForExecutable['git'] = [ - MockProcess.succeeding(), + MockProcess(stdout: changedFilePath), ]; - const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; - processRunner.resultStdout = changedFilePath; + Error? commandError; final List output = await runCapturingPrint(runner, ['format', '--fail-on-change'], @@ -448,7 +448,7 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['git'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; final List output = @@ -472,12 +472,12 @@ void main() { ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); + const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; processRunner.mockProcessesForExecutable['git'] = [ - MockProcess.succeeding(), // ls-files - MockProcess.failing(), // diff + MockProcess(stdout: changedFilePath), // ls-files + MockProcess(exitCode: 1), // diff ]; - const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; - processRunner.resultStdout = changedFilePath; + Error? commandError; final List output = await runCapturingPrint(runner, ['format', '--fail-on-change'], diff --git a/script/tool/test/lint_android_command_test.dart b/script/tool/test/lint_android_command_test.dart index 05ead220c15b..d08058468636 100644 --- a/script/tool/test/lint_android_command_test.dart +++ b/script/tool/test/lint_android_command_test.dart @@ -101,7 +101,7 @@ void main() { }); processRunner.mockProcessesForExecutable['gradlew'] = [ - MockProcess.failing(), + MockProcess(exitCode: 1), ]; Error? commandError; diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart index 51a4e6267770..44247274028f 100644 --- a/script/tool/test/lint_podspecs_command_test.dart +++ b/script/tool/test/lint_podspecs_command_test.dart @@ -75,11 +75,9 @@ void main() { ); processRunner.mockProcessesForExecutable['pod'] = [ - MockProcess.succeeding(), - MockProcess.succeeding(), + MockProcess(stdout: 'Foo', stderr: 'Bar'), + MockProcess(), ]; - processRunner.resultStdout = 'Foo'; - processRunner.resultStderr = 'Bar'; final List output = await runCapturingPrint(runner, ['podspecs']); @@ -173,7 +171,7 @@ void main() { // Simulate failure from `which pod`. processRunner.mockProcessesForExecutable['which'] = [ - MockProcess.failing(), + MockProcess(exitCode: 1), ]; Error? commandError; @@ -199,7 +197,7 @@ void main() { // Simulate failure from `pod`. processRunner.mockProcessesForExecutable['pod'] = [ - MockProcess.failing(), + MockProcess(exitCode: 1), ]; Error? commandError; @@ -227,8 +225,8 @@ void main() { // Simulate failure from the second call to `pod`. processRunner.mockProcessesForExecutable['pod'] = [ - MockProcess.succeeding(), - MockProcess.failing(), + MockProcess(), + MockProcess(exitCode: 1), ]; Error? commandError; diff --git a/script/tool/test/mocks.dart b/script/tool/test/mocks.dart index 0dcdedd3db03..3d0aef1b3971 100644 --- a/script/tool/test/mocks.dart +++ b/script/tool/test/mocks.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; @@ -32,22 +33,32 @@ class MockPlatform extends Mock implements Platform { } class MockProcess extends Mock implements io.Process { - MockProcess(); - - /// A mock process that terminates with exitCode 0. - MockProcess.succeeding() { - exitCodeCompleter.complete(0); - } - - /// A mock process that terminates with exitCode 1. - MockProcess.failing() { - exitCodeCompleter.complete(1); + /// Creates a mock process with the given results. + /// + /// The default encodings match the ProcessRunner defaults; mocks for + /// processes run with a different encoding will need to be created with + /// the matching encoding. + MockProcess({ + int exitCode = 0, + String? stdout, + String? stderr, + Encoding stdoutEncoding = io.systemEncoding, + Encoding stderrEncoding = io.systemEncoding, + }) : _exitCode = exitCode { + if (stdout != null) { + _stdoutController.add(stdoutEncoding.encoder.convert(stdout)); + } + if (stderr != null) { + _stderrController.add(stderrEncoding.encoder.convert(stderr)); + } + _stdoutController.close(); + _stderrController.close(); } - final Completer exitCodeCompleter = Completer(); - final StreamController> stdoutController = + final int _exitCode; + final StreamController> _stdoutController = StreamController>(); - final StreamController> stderrController = + final StreamController> _stderrController = StreamController>(); final MockIOSink stdinMock = MockIOSink(); @@ -55,13 +66,13 @@ class MockProcess extends Mock implements io.Process { int get pid => 99; @override - Future get exitCode => exitCodeCompleter.future; + Future get exitCode async => _exitCode; @override - Stream> get stdout => stdoutController.stream; + Stream> get stdout => _stdoutController.stream; @override - Stream> get stderr => stderrController.stream; + Stream> get stderr => _stderrController.stream; @override IOSink get stdin => stdinMock; diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index 59ca17b25c0b..f367dc80182f 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -122,11 +122,9 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - // Exit code 66 from testing indicates no tests. - final MockProcess noTestsProcessResult = MockProcess(); - noTestsProcessResult.exitCodeCompleter.complete(66); processRunner.mockProcessesForExecutable['xcrun'] = [ - noTestsProcessResult, + // Exit code 66 from testing indicates no tests. + MockProcess(exitCode: 66), ]; final List output = await runCapturingPrint(runner, ['native-test', '--macos']); @@ -239,12 +237,13 @@ void main() { 'plugin', packagesDir, platformSupport: { kPlatformIos: PlatformSupport.inline }); - final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = jsonEncode(_kDeviceListMap); + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(_kDeviceListMap)), // simctl + ]; + await runCapturingPrint(runner, ['native-test', '--ios']); expect( @@ -673,7 +672,7 @@ void main() { .childFile('gradlew') .path; processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; @@ -745,7 +744,7 @@ void main() { }); processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; @@ -775,9 +774,14 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["RunnerTests", "RunnerUITests"]}}'; + const Map projects = { + 'project': { + 'targets': ['RunnerTests', 'RunnerUITests'] + } + }; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + ]; final List output = await runCapturingPrint(runner, [ 'native-test', @@ -835,9 +839,14 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); - processRunner.resultStdout = - '{"project":{"targets":["RunnerTests", "RunnerUITests"]}}'; + const Map projects = { + 'project': { + 'targets': ['RunnerTests', 'RunnerUITests'] + } + }; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + ]; final List output = await runCapturingPrint(runner, [ 'native-test', @@ -895,9 +904,16 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.succeeding(); // Simulate a project with unit tests but no integration tests... - processRunner.resultStdout = '{"project":{"targets":["RunnerTests"]}}'; + const Map projects = { + 'project': { + 'targets': ['RunnerTests'] + } + }; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + ]; + // ... then try to run only integration tests. final List output = await runCapturingPrint(runner, [ 'native-test', @@ -941,7 +957,9 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - processRunner.processToReturn = MockProcess.failing(); + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1), // xcodebuild -list + ]; Error? commandError; final List output = await runCapturingPrint(runner, [ @@ -1192,7 +1210,7 @@ void main() { .childFile('gradlew') .path; processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; @@ -1243,11 +1261,11 @@ void main() { .childFile('gradlew') .path; processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; // Simulate failing Android. processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 11de9f095481..65b0cb54547c 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:collection'; import 'dart:convert'; import 'dart:io' as io; @@ -19,18 +18,18 @@ import 'mocks.dart'; import 'util.dart'; void main() { - group('$PublishCheckProcessRunner tests', () { + group('$PublishCheckCommand tests', () { FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; - late PublishCheckProcessRunner processRunner; + late RecordingProcessRunner processRunner; late CommandRunner runner; setUp(() { fileSystem = MemoryFileSystem(); mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); - processRunner = PublishCheckProcessRunner(); + processRunner = RecordingProcessRunner(); final PublishCheckCommand publishCheckCommand = PublishCheckCommand( packagesDir, processRunner: processRunner, @@ -50,12 +49,11 @@ void main() { final Directory plugin2Dir = createFakePlugin('plugin_tools_test_package_b', packagesDir); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(), + MockProcess(), + ]; + await runCapturingPrint(runner, ['publish-check']); expect( @@ -75,11 +73,9 @@ void main() { test('fail on negative test', () async { createFakePlugin('plugin_tools_test_package_a', packagesDir); - final MockProcess process = MockProcess.failing(); - process.stdoutController.close(); // ignore: unawaited_futures - process.stderrController.close(); // ignore: unawaited_futures - - processRunner.processesToReturn.add(process); + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(exitCode: 1) + ]; expect( () => runCapturingPrint(runner, ['publish-check']), @@ -91,8 +87,9 @@ void main() { final Directory dir = createFakePlugin('c', packagesDir); await dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - final MockProcess process = MockProcess(); - processRunner.processesToReturn.add(process); + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(), + ]; expect(() => runCapturingPrint(runner, ['publish-check']), throwsA(isA())); @@ -101,15 +98,14 @@ void main() { test('pass on prerelease if --allow-pre-release flag is on', () async { createFakePlugin('d', packagesDir); - const String preReleaseOutput = 'Package has 1 warning.' - 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'; - - final MockProcess process = MockProcess.failing(); - process.stdoutController.add(preReleaseOutput.codeUnits); - process.stdoutController.close(); // ignore: unawaited_futures - process.stderrController.close(); // ignore: unawaited_futures - - processRunner.processesToReturn.add(process); + final MockProcess process = MockProcess( + exitCode: 1, + stdout: 'Package has 1 warning.\n' + 'Packages with an SDK constraint on a pre-release of the Dart ' + 'SDK should themselves be published as a pre-release version.'); + processRunner.mockProcessesForExecutable['flutter'] = [ + process, + ]; expect( runCapturingPrint( @@ -120,15 +116,14 @@ void main() { test('fail on prerelease if --allow-pre-release flag is off', () async { createFakePlugin('d', packagesDir); - const String preReleaseOutput = 'Package has 1 warning.' - 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'; - - final MockProcess process = MockProcess.failing(); - process.stdoutController.add(preReleaseOutput.codeUnits); - process.stdoutController.close(); // ignore: unawaited_futures - process.stderrController.close(); // ignore: unawaited_futures - - processRunner.processesToReturn.add(process); + final MockProcess process = MockProcess( + exitCode: 1, + stdout: 'Package has 1 warning.\n' + 'Packages with an SDK constraint on a pre-release of the Dart ' + 'SDK should themselves be published as a pre-release version.'); + processRunner.mockProcessesForExecutable['flutter'] = [ + process, + ]; expect(runCapturingPrint(runner, ['publish-check']), throwsA(isA())); @@ -137,14 +132,9 @@ void main() { test('Success message on stderr is not printed as an error', () async { createFakePlugin('d', packagesDir); - const String publishOutput = 'Package has 0 warnings.'; - - final MockProcess process = MockProcess.succeeding(); - process.stderrController.add(publishOutput.codeUnits); - process.stdoutController.close(); // ignore: unawaited_futures - process.stderrController.close(); // ignore: unawaited_futures - - processRunner.processesToReturn.add(process); + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(stdout: 'Package has 0 warnings.'), + ]; final List output = await runCapturingPrint(runner, ['publish-check']); @@ -192,9 +182,6 @@ void main() { createFakePlugin('no_publish_a', packagesDir, version: '0.1.0'); createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); final List output = await runCapturingPrint( runner, ['publish-check', '--machine']); @@ -258,9 +245,9 @@ void main() { createFakePlugin('no_publish_a', packagesDir, version: '0.1.0'); createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(), + ]; final List output = await runCapturingPrint( runner, ['publish-check', '--machine']); @@ -331,9 +318,9 @@ void main() { await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(), + ]; bool hasError = false; final List output = await runCapturingPrint( @@ -369,10 +356,3 @@ void main() { }); }); } - -class PublishCheckProcessRunner extends RecordingProcessRunner { - final Queue processesToReturn = Queue(); - - @override - io.Process get processToReturn => processesToReturn.removeFirst(); -} diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index c7df81952641..9a937daa2384 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -1060,7 +1060,7 @@ class TestProcessRunner extends ProcessRunner { String? mockPublishStdout; String? mockPublishStderr; - int? mockPublishCompleteCode; + int mockPublishCompleteCode = 0; @override Future run( @@ -1097,17 +1097,14 @@ class TestProcessRunner extends ProcessRunner { args[0] == 'pub' && args[1] == 'publish'); mockPublishArgs.addAll(args); - mockPublishProcess = MockProcess(); - if (mockPublishStdout != null) { - mockPublishProcess.stdoutController.add(utf8.encode(mockPublishStdout!)); - } - if (mockPublishStderr != null) { - mockPublishProcess.stderrController.add(utf8.encode(mockPublishStderr!)); - } - if (mockPublishCompleteCode != null) { - mockPublishProcess.exitCodeCompleter.complete(mockPublishCompleteCode); - } + mockPublishProcess = MockProcess( + exitCode: mockPublishCompleteCode, + stdout: mockPublishStdout, + stderr: mockPublishStderr, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); return mockPublishProcess; } } diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 503e24d03056..3b350f7d88ae 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -67,8 +67,8 @@ void main() { processRunner .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [ - MockProcess.failing(), // plugin 1 test - MockProcess.succeeding(), // plugin 2 test + MockProcess(exitCode: 1), // plugin 1 test + MockProcess(), // plugin 2 test ]; Error? commandError; @@ -132,7 +132,7 @@ void main() { extraFiles: ['test/empty_test.dart']); processRunner.mockProcessesForExecutable['dart'] = [ - MockProcess.failing(), // dart pub get + MockProcess(exitCode: 1), // dart pub get ]; Error? commandError; @@ -156,8 +156,8 @@ void main() { extraFiles: ['test/empty_test.dart']); processRunner.mockProcessesForExecutable['dart'] = [ - MockProcess.succeeding(), // dart pub get - MockProcess.failing(), // dart pub run test + MockProcess(), // dart pub get + MockProcess(exitCode: 1), // dart pub run test ]; Error? commandError; diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 1984a25cc430..10a85f49e815 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -265,15 +265,6 @@ class RecordingProcessRunner extends ProcessRunner { final Map> mockProcessesForExecutable = >{}; - /// Populate for [io.ProcessResult] to use a String [stdout] instead of a [List] of [int]. - String? resultStdout; - - /// Populate for [io.ProcessResult] to use a String [stderr] instead of a [List] of [int]. - String? resultStderr; - - // Deprecated--do not add new uses. Use mockProcessesForExecutable instead. - io.Process? processToReturn; - @override Future runAndStream( String executable, @@ -291,8 +282,7 @@ class RecordingProcessRunner extends ProcessRunner { return Future.value(exitCode); } - /// Returns [io.ProcessResult] created from [mockProcessesForExecutable], - /// [resultStdout], and [resultStderr]. + /// Returns [io.ProcessResult] created from [mockProcessesForExecutable]. @override Future run( String executable, @@ -306,10 +296,16 @@ class RecordingProcessRunner extends ProcessRunner { recordedCalls.add(ProcessCall(executable, args, workingDir?.path)); final io.Process? process = _getProcessToReturn(executable); + final List? processStdout = + await process?.stdout.transform(stdoutEncoding.decoder).toList(); + final String stdout = processStdout?.join('') ?? ''; + final List? processStderr = + await process?.stderr.transform(stderrEncoding.decoder).toList(); + final String stderr = processStderr?.join('') ?? ''; + final io.ProcessResult result = process == null ? io.ProcessResult(1, 0, '', '') - : io.ProcessResult(process.pid, await process.exitCode, - resultStdout ?? process.stdout, resultStderr ?? process.stderr); + : io.ProcessResult(process.pid, await process.exitCode, stdout, stderr); if (exitOnError && (result.exitCode != 0)) { throw io.ProcessException(executable, args); @@ -326,13 +322,11 @@ class RecordingProcessRunner extends ProcessRunner { } io.Process? _getProcessToReturn(String executable) { - io.Process? process; final List? processes = mockProcessesForExecutable[executable]; if (processes != null && processes.isNotEmpty) { - process = mockProcessesForExecutable[executable]!.removeAt(0); + return processes.removeAt(0); } - // Fall back to `processToReturn` for backwards compatibility. - return process ?? processToReturn; + return null; } } diff --git a/script/tool/test/xcode_analyze_command_test.dart b/script/tool/test/xcode_analyze_command_test.dart index b715ac531f50..790a526a8ae0 100644 --- a/script/tool/test/xcode_analyze_command_test.dart +++ b/script/tool/test/xcode_analyze_command_test.dart @@ -131,7 +131,7 @@ void main() { }); processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; @@ -228,7 +228,7 @@ void main() { }); processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; From fb6622092bb8f1a56b701f6c6ede551b6c986d06 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 24 Aug 2021 16:29:56 -0400 Subject: [PATCH 215/364] [flutter_plugin_tools] Introduce a class for packages (#4252) Packages are the primary conceptual object in the tool, but currently they are represented simply as Directory (or occasionally a path string). This introduces an object for packages and: - moves a number of existing utility methods into it - sweeps the code for the obvious cases of using `Directory` to represent a package, especially in method signatures and migrates them - notes a few places where we should migrate later, to avoid ballooning the size of the PR There are no doubt other cases not caught in the sweep, but this gives us a foundation both for new code, and to migrate incrementally toward as we find existing code that was missed. --- script/tool/lib/src/analyze_command.dart | 14 +- .../tool/lib/src/build_examples_command.dart | 11 +- .../src/common/package_looping_command.dart | 86 +++++------- .../tool/lib/src/common/plugin_command.dart | 54 +++----- script/tool/lib/src/common/plugin_utils.dart | 42 +----- .../lib/src/common/pub_version_finder.dart | 6 +- .../lib/src/common/repository_package.dart | 78 +++++++++++ .../src/create_all_plugins_app_command.dart | 6 +- .../tool/lib/src/drive_examples_command.dart | 34 ++--- .../lib/src/firebase_test_lab_command.dart | 26 ++-- script/tool/lib/src/lint_android_command.dart | 9 +- .../tool/lib/src/lint_podspecs_command.dart | 5 +- script/tool/lib/src/list_command.dart | 18 +-- script/tool/lib/src/native_test_command.dart | 50 +++---- .../tool/lib/src/publish_check_command.dart | 18 +-- .../tool/lib/src/publish_plugin_command.dart | 14 +- .../tool/lib/src/pubspec_check_command.dart | 19 +-- script/tool/lib/src/test_command.dart | 19 +-- .../tool/lib/src/version_check_command.dart | 19 +-- .../tool/lib/src/xcode_analyze_command.dart | 13 +- .../common/package_looping_command_test.dart | 67 +--------- .../tool/test/common/plugin_command_test.dart | 10 +- .../tool/test/common/plugin_utils_test.dart | 21 +-- .../test/common/pub_version_finder_test.dart | 6 +- .../test/common/repository_package_test.dart | 123 ++++++++++++++++++ script/tool/test/format_command_test.dart | 4 +- script/tool/test/util.dart | 2 + 27 files changed, 440 insertions(+), 334 deletions(-) create mode 100644 script/tool/lib/src/common/repository_package.dart create mode 100644 script/tool/test/common/repository_package_test.dart diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index 2b728e2b9073..faad7f4736eb 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -5,13 +5,14 @@ import 'dart:async'; import 'package:file/file.dart'; -import 'package:flutter_plugin_tools/src/common/plugin_command.dart'; import 'package:platform/platform.dart'; import 'package:yaml/yaml.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; +import 'common/plugin_command.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; const int _exitPackagesGetFailed = 3; @@ -55,8 +56,9 @@ class AnalyzeCommand extends PackageLoopingCommand { final bool hasLongOutput = false; /// Checks that there are no unexpected analysis_options.yaml files. - bool _hasUnexpecetdAnalysisOptions(Directory package) { - final List files = package.listSync(recursive: true); + bool _hasUnexpecetdAnalysisOptions(RepositoryPackage package) { + final List files = + package.directory.listSync(recursive: true); for (final FileSystemEntity file in files) { if (file.basename != 'analysis_options.yaml' && file.basename != '.analysis_options') { @@ -87,7 +89,7 @@ class AnalyzeCommand extends PackageLoopingCommand { Future _runPackagesGetOnTargetPackages() async { final List packageDirectories = await getTargetPackagesAndSubpackages() - .map((PackageEnumerationEntry package) => package.directory) + .map((PackageEnumerationEntry entry) => entry.package.directory) .toList(); final Set packagePaths = packageDirectories.map((Directory dir) => dir.path).toSet(); @@ -135,13 +137,13 @@ class AnalyzeCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { if (_hasUnexpecetdAnalysisOptions(package)) { return PackageResult.fail(['Unexpected local analysis options']); } final int exitCode = await processRunner.runAndStream( _dartBinaryPath, ['analyze', '--fatal-infos'], - workingDir: package); + workingDir: package.directory); if (exitCode != 0) { return PackageResult.fail(); } diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 0cac09980c94..ac5e84b7c3c7 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -11,6 +11,7 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; /// Key for APK. const String _platformFlagApk = 'apk'; @@ -96,7 +97,7 @@ class BuildExamplesCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { final List errors = []; final Iterable<_PlatformDetails> requestedPlatforms = _platforms.entries @@ -126,9 +127,9 @@ class BuildExamplesCommand extends PackageLoopingCommand { } print(''); - for (final Directory example in getExamplesForPlugin(package)) { + for (final RepositoryPackage example in package.getExamples()) { final String packageName = - getRelativePosixPath(example, from: packagesDir); + getRelativePosixPath(example.directory, from: packagesDir); for (final _PlatformDetails platform in buildPlatforms) { String buildPlatform = platform.label; @@ -149,7 +150,7 @@ class BuildExamplesCommand extends PackageLoopingCommand { } Future _buildExample( - Directory example, + RepositoryPackage example, String flutterBuildType, { List extraBuildFlags = const [], }) async { @@ -164,7 +165,7 @@ class BuildExamplesCommand extends PackageLoopingCommand { if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], - workingDir: example, + workingDir: example.directory, ); return exitCode == 0; } diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 0e0976ecc6a7..00caeb30ef42 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -13,6 +13,7 @@ import 'package:platform/platform.dart'; import 'core.dart'; import 'plugin_command.dart'; import 'process_runner.dart'; +import 'repository_package.dart'; /// Possible outcomes of a command run for a package. enum RunState { @@ -84,7 +85,7 @@ abstract class PackageLoopingCommand extends PluginCommand { int _otherWarningCount = 0; /// The package currently being run by [runForPackage]. - PackageEnumerationEntry? _currentPackage; + PackageEnumerationEntry? _currentPackageEntry; /// Called during [run] before any calls to [runForPackage]. This provides an /// opportunity to fail early if the command can't be run (e.g., because the @@ -97,7 +98,7 @@ abstract class PackageLoopingCommand extends PluginCommand { /// be included in the final error summary (e.g., a command that only has a /// single failure mode), or strings that should be listed for that package /// in the final summary. An empty list indicates success. - Future runForPackage(Directory package); + Future runForPackage(RepositoryPackage package); /// Called during [run] after all calls to [runForPackage]. This provides an /// opportunity to do any cleanup of run-level state. @@ -155,31 +156,13 @@ abstract class PackageLoopingCommand extends PluginCommand { /// things that might be useful to someone debugging an unexpected result. void logWarning(String warningMessage) { print(Colorize(warningMessage)..yellow()); - if (_currentPackage != null) { - _packagesWithWarnings.add(_currentPackage!); + if (_currentPackageEntry != null) { + _packagesWithWarnings.add(_currentPackageEntry!); } else { ++_otherWarningCount; } } - /// Returns the identifying name to use for [package]. - /// - /// Implementations should not expect a specific format for this string, since - /// it uses heuristics to try to be precise without being overly verbose. If - /// an exact format (e.g., published name, or basename) is required, that - /// should be used instead. - String getPackageDescription(Directory package) { - String packageName = getRelativePosixPath(package, from: packagesDir); - final List components = p.posix.split(packageName); - // For the common federated plugin pattern of `foo/foo_subpackage`, drop - // the first part since it's not useful. - if (components.length >= 2 && - components[1].startsWith('${components[0]}_')) { - packageName = p.posix.joinAll(components.sublist(1)); - } - return packageName; - } - /// Returns the relative path from [from] to [entity] in Posix style. /// /// This should be used when, for example, printing package-relative paths in @@ -219,36 +202,36 @@ abstract class PackageLoopingCommand extends PluginCommand { Future _runInternal() async { _packagesWithWarnings.clear(); _otherWarningCount = 0; - _currentPackage = null; + _currentPackageEntry = null; await initializeRun(); - final List packages = includeSubpackages + final List targetPackages = includeSubpackages ? await getTargetPackagesAndSubpackages(filterExcluded: false).toList() : await getTargetPackages(filterExcluded: false).toList(); final Map results = {}; - for (final PackageEnumerationEntry package in packages) { - _currentPackage = package; - _printPackageHeading(package); + for (final PackageEnumerationEntry entry in targetPackages) { + _currentPackageEntry = entry; + _printPackageHeading(entry); // Command implementations should never see excluded packages; they are // included at this level only for logging. - if (package.excluded) { - results[package] = PackageResult.exclude(); + if (entry.excluded) { + results[entry] = PackageResult.exclude(); continue; } - final PackageResult result = await runForPackage(package.directory); + final PackageResult result = await runForPackage(entry.package); if (result.state == RunState.skipped) { final String message = '${indentation}SKIPPING: ${result.details.first}'; captureOutput ? print(message) : print(Colorize(message)..darkGray()); } - results[package] = result; + results[entry] = result; } - _currentPackage = null; + _currentPackageEntry = null; completeRun(); @@ -256,13 +239,13 @@ abstract class PackageLoopingCommand extends PluginCommand { // If there were any errors reported, summarize them and exit. if (results.values .any((PackageResult result) => result.state == RunState.failed)) { - _printFailureSummary(packages, results); + _printFailureSummary(targetPackages, results); return false; } // Otherwise, print a summary of what ran for ease of auditing that all the // expected tests ran. - _printRunSummary(packages, results); + _printRunSummary(targetPackages, results); print('\n'); _printSuccess('No issues found!'); @@ -283,9 +266,9 @@ abstract class PackageLoopingCommand extends PluginCommand { /// Something is always printed to make it easier to distinguish between /// a command running for a package and producing no output, and a command /// not having been run for a package. - void _printPackageHeading(PackageEnumerationEntry package) { - final String packageDisplayName = getPackageDescription(package.directory); - String heading = package.excluded + void _printPackageHeading(PackageEnumerationEntry entry) { + final String packageDisplayName = entry.package.displayName; + String heading = entry.excluded ? 'Not running for $packageDisplayName; excluded' : 'Running for $packageDisplayName'; if (hasLongOutput) { @@ -295,16 +278,15 @@ abstract class PackageLoopingCommand extends PluginCommand { || $heading ============================================================ '''; - } else if (!package.excluded) { + } else if (!entry.excluded) { heading = '$heading...'; } if (captureOutput) { print(heading); } else { final Colorize colorizeHeading = Colorize(heading); - print(package.excluded - ? colorizeHeading.darkGray() - : colorizeHeading.cyan()); + print( + entry.excluded ? colorizeHeading.darkGray() : colorizeHeading.cyan()); } } @@ -349,17 +331,18 @@ abstract class PackageLoopingCommand extends PluginCommand { /// Prints a one-line-per-package overview of the run results for each /// package. - void _printPerPackageRunOverview(List packages, + void _printPerPackageRunOverview( + List packageEnumeration, {required Set skipped}) { print('Run overview:'); - for (final PackageEnumerationEntry package in packages) { - final bool hadWarning = _packagesWithWarnings.contains(package); + for (final PackageEnumerationEntry entry in packageEnumeration) { + final bool hadWarning = _packagesWithWarnings.contains(entry); Styles style; String summary; - if (package.excluded) { + if (entry.excluded) { summary = 'excluded'; style = Styles.DARK_GRAY; - } else if (skipped.contains(package)) { + } else if (skipped.contains(entry)) { summary = 'skipped'; style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY; } else { @@ -373,18 +356,18 @@ abstract class PackageLoopingCommand extends PluginCommand { if (!captureOutput) { summary = (Colorize(summary)..apply(style)).toString(); } - print(' ${getPackageDescription(package.directory)} - $summary'); + print(' ${entry.package.displayName} - $summary'); } print(''); } /// Prints a summary of all of the failures from [results]. - void _printFailureSummary(List packages, + void _printFailureSummary(List packageEnumeration, Map results) { const String indentation = ' '; _printError(failureListHeader); - for (final PackageEnumerationEntry package in packages) { - final PackageResult result = results[package]!; + for (final PackageEnumerationEntry entry in packageEnumeration) { + final PackageResult result = results[entry]!; if (result.state == RunState.failed) { final String errorIndentation = indentation * 2; String errorDetails = ''; @@ -392,8 +375,7 @@ abstract class PackageLoopingCommand extends PluginCommand { errorDetails = ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; } - _printError( - '$indentation${getPackageDescription(package.directory)}$errorDetails'); + _printError('$indentation${entry.package.displayName}$errorDetails'); } } _printError(failureListFooter); diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 10f423360878..ec51261ab617 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -14,15 +14,18 @@ import 'package:yaml/yaml.dart'; import 'core.dart'; import 'git_version_finder.dart'; import 'process_runner.dart'; +import 'repository_package.dart'; /// An entry in package enumeration for APIs that need to include extra /// data about the entry. class PackageEnumerationEntry { - /// Creates a new entry for the given package directory. - PackageEnumerationEntry(this.directory, {required this.excluded}); + /// Creates a new entry for the given package. + PackageEnumerationEntry(this.package, {required this.excluded}); - /// The package's location. - final Directory directory; + /// The package this entry corresponds to. Be sure to check `excluded` before + /// using this, as having an entry does not necessarily mean that the package + /// should be included in the processing of the enumeration. + final RepositoryPackage package; /// Whether or not this package was excluded by the command invocation. final bool excluded; @@ -225,7 +228,7 @@ abstract class PluginCommand extends Command { final List allPlugins = await _getAllPackages().toList(); allPlugins.sort((PackageEnumerationEntry p1, PackageEnumerationEntry p2) => - p1.directory.path.compareTo(p2.directory.path)); + p1.package.path.compareTo(p2.package.path)); final int shardSize = allPlugins.length ~/ shardCount + (allPlugins.length % shardCount == 0 ? 0 : 1); final int start = min(shardIndex * shardSize, allPlugins.length); @@ -287,7 +290,8 @@ abstract class PluginCommand extends Command { // A top-level Dart package is a plugin package. if (_isDartPackage(entity)) { if (plugins.isEmpty || plugins.contains(p.basename(entity.path))) { - yield PackageEnumerationEntry(entity as Directory, + yield PackageEnumerationEntry( + RepositoryPackage(entity as Directory), excluded: excludedPluginNames.contains(entity.basename)); } } else if (entity is Directory) { @@ -305,7 +309,8 @@ abstract class PluginCommand extends Command { if (plugins.isEmpty || plugins.contains(relativePath) || plugins.contains(basenamePath)) { - yield PackageEnumerationEntry(subdir as Directory, + yield PackageEnumerationEntry( + RepositoryPackage(subdir as Directory), excluded: excludedPluginNames.contains(basenamePath) || excludedPluginNames.contains(packageName) || excludedPluginNames.contains(relativePath)); @@ -327,26 +332,26 @@ abstract class PluginCommand extends Command { await for (final PackageEnumerationEntry plugin in getTargetPackages(filterExcluded: filterExcluded)) { yield plugin; - yield* plugin.directory + yield* plugin.package.directory .list(recursive: true, followLinks: false) .where(_isDartPackage) .map((FileSystemEntity directory) => PackageEnumerationEntry( - directory as Directory, // _isDartPackage guarantees this works. + // _isDartPackage guarantees that this cast is valid. + RepositoryPackage(directory as Directory), excluded: plugin.excluded)); } } - /// Returns the files contained, recursively, within the plugins + /// Returns the files contained, recursively, within the packages /// involved in this command execution. Stream getFiles() { - return getTargetPackages() - .map((PackageEnumerationEntry entry) => entry.directory) - .asyncExpand((Directory folder) => getFilesForPackage(folder)); + return getTargetPackages().asyncExpand( + (PackageEnumerationEntry entry) => getFilesForPackage(entry.package)); } /// Returns the files contained, recursively, within [package]. - Stream getFilesForPackage(Directory package) { - return package + Stream getFilesForPackage(RepositoryPackage package) { + return package.directory .list(recursive: true, followLinks: false) .where((FileSystemEntity entity) => entity is File) .cast(); @@ -358,25 +363,6 @@ abstract class PluginCommand extends Command { return entity is Directory && entity.childFile('pubspec.yaml').existsSync(); } - /// Returns the example Dart packages contained in the specified plugin, or - /// an empty List, if the plugin has no examples. - Iterable getExamplesForPlugin(Directory plugin) { - final Directory exampleFolder = plugin.childDirectory('example'); - if (!exampleFolder.existsSync()) { - return []; - } - if (isFlutterPackage(exampleFolder)) { - return [exampleFolder]; - } - // Only look at the subdirectories of the example directory if the example - // directory itself is not a Dart package, and only look one level below the - // example directory for other dart packages. - return exampleFolder - .listSync() - .where((FileSystemEntity entity) => isFlutterPackage(entity)) - .cast(); - } - /// Retrieve an instance of [GitVersionFinder] based on `_kBaseSha` and [gitDir]. /// /// Throws tool exit if [gitDir] nor root directory is a git directory. diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart index 0277b78d566a..d9c42e220c0b 100644 --- a/script/tool/lib/src/common/plugin_utils.dart +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; import 'package:yaml/yaml.dart'; import 'core.dart'; @@ -16,7 +17,7 @@ enum PlatformSupport { federated, } -/// Returns whether the given directory contains a Flutter [platform] plugin. +/// Returns whether the given [package] is a Flutter [platform] plugin. /// /// It checks this by looking for the following pattern in the pubspec: /// @@ -27,7 +28,7 @@ enum PlatformSupport { /// /// If [requiredMode] is provided, the plugin must have the given type of /// implementation in order to return true. -bool pluginSupportsPlatform(String platform, FileSystemEntity entity, +bool pluginSupportsPlatform(String platform, RepositoryPackage package, {PlatformSupport? requiredMode}) { assert(platform == kPlatformIos || platform == kPlatformAndroid || @@ -35,14 +36,9 @@ bool pluginSupportsPlatform(String platform, FileSystemEntity entity, platform == kPlatformMacos || platform == kPlatformWindows || platform == kPlatformLinux); - if (entity is! Directory) { - return false; - } - try { - final File pubspecFile = entity.childFile('pubspec.yaml'); final YamlMap pubspecYaml = - loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + loadYaml(package.pubspecFile.readAsStringSync()) as YamlMap; final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; if (flutterSection == null) { return false; @@ -78,33 +74,3 @@ bool pluginSupportsPlatform(String platform, FileSystemEntity entity, return false; } } - -/// Returns whether the given directory contains a Flutter Android plugin. -bool isAndroidPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformAndroid, entity); -} - -/// Returns whether the given directory contains a Flutter iOS plugin. -bool isIosPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformIos, entity); -} - -/// Returns whether the given directory contains a Flutter web plugin. -bool isWebPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformWeb, entity); -} - -/// Returns whether the given directory contains a Flutter Windows plugin. -bool isWindowsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformWindows, entity); -} - -/// Returns whether the given directory contains a Flutter macOS plugin. -bool isMacOsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformMacos, entity); -} - -/// Returns whether the given directory contains a Flutter linux plugin. -bool isLinuxPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformLinux, entity); -} diff --git a/script/tool/lib/src/common/pub_version_finder.dart b/script/tool/lib/src/common/pub_version_finder.dart index ebac473de7ac..572cb913aa7d 100644 --- a/script/tool/lib/src/common/pub_version_finder.dart +++ b/script/tool/lib/src/common/pub_version_finder.dart @@ -27,10 +27,10 @@ class PubVersionFinder { /// Get the package version on pub. Future getPackageVersion( - {required String package}) async { - assert(package.isNotEmpty); + {required String packageName}) async { + assert(packageName.isNotEmpty); final Uri pubHostUri = Uri.parse(pubHost); - final Uri url = pubHostUri.replace(path: '/packages/$package.json'); + final Uri url = pubHostUri.replace(path: '/packages/$packageName.json'); final http.Response response = await httpClient.get(url); if (response.statusCode == 404) { diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart new file mode 100644 index 000000000000..f6601d39b79e --- /dev/null +++ b/script/tool/lib/src/common/repository_package.dart @@ -0,0 +1,78 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'core.dart'; + +/// A package in the repository. +// +// TODO(stuartmorgan): Add more package-related info here, such as an on-demand +// cache of the parsed pubspec. +class RepositoryPackage { + /// Creates a representation of the package at [directory]. + RepositoryPackage(this.directory); + + /// The location of the package. + final Directory directory; + + /// The path to the package. + String get path => directory.path; + + /// Returns the string to use when referring to the package in user-targeted + /// messages. + /// + /// Callers should not expect a specific format for this string, since + /// it uses heuristics to try to be precise without being overly verbose. If + /// an exact format (e.g., published name, or basename) is required, that + /// should be used instead. + String get displayName { + List components = directory.fileSystem.path.split(directory.path); + // Remove everything up to the packages directory. + final int packagesIndex = components.indexOf('packages'); + if (packagesIndex != -1) { + components = components.sublist(packagesIndex + 1); + } + // For the common federated plugin pattern of `foo/foo_subpackage`, drop + // the first part since it's not useful. + if (components.length >= 2 && + components[1].startsWith('${components[0]}_')) { + components = components.sublist(1); + } + return p.posix.joinAll(components); + } + + /// The package's top-level pubspec.yaml. + File get pubspecFile => directory.childFile('pubspec.yaml'); + + /// Returns the Flutter example packages contained in the package, if any. + Iterable getExamples() { + final Directory exampleDirectory = directory.childDirectory('example'); + if (!exampleDirectory.existsSync()) { + return []; + } + if (isFlutterPackage(exampleDirectory)) { + return [RepositoryPackage(exampleDirectory)]; + } + // Only look at the subdirectories of the example directory if the example + // directory itself is not a Dart package, and only look one level below the + // example directory for other Dart packages. + return exampleDirectory + .listSync() + .where((FileSystemEntity entity) => isFlutterPackage(entity)) + // isFlutterPackage guarantees that the cast to Directory is safe. + .map((FileSystemEntity entity) => + RepositoryPackage(entity as Directory)); + } + + /// Returns the example directory, assuming there is only one. + /// + /// DO NOT USE THIS METHOD. It exists only to easily find code that was + /// written to use a single example and needs to be restructured to handle + /// multiple examples. New code should always use [getExamples]. + // TODO(stuartmorgan): Eliminate all uses of this. + RepositoryPackage getSingleExampleDeprecated() => + RepositoryPackage(directory.childDirectory('example')); +} diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index e1cee6f3fe7d..6dbebf2f5c74 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -11,6 +11,7 @@ import 'package:pubspec_parse/pubspec_parse.dart'; import 'common/core.dart'; import 'common/plugin_command.dart'; +import 'common/repository_package.dart'; const String _outputDirectoryFlag = 'output-dir'; @@ -170,10 +171,11 @@ class CreateAllPluginsAppCommand extends PluginCommand { final Map pathDependencies = {}; - await for (final PackageEnumerationEntry package in getTargetPackages()) { + await for (final PackageEnumerationEntry entry in getTargetPackages()) { + final RepositoryPackage package = entry.package; final Directory pluginDirectory = package.directory; final String pluginName = pluginDirectory.basename; - final File pubspecFile = pluginDirectory.childFile('pubspec.yaml'); + final File pubspecFile = package.pubspecFile; final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); if (pubspec.publishTo != 'none') { diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 7e800ed54866..3605dcce1f22 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -12,6 +12,7 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; const int _exitNoPlatformFlags = 2; const int _exitNoAvailableDevice = 3; @@ -119,9 +120,9 @@ class DriveExamplesCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { - if (package.basename.endsWith('_platform_interface') && - !package.childDirectory('example').existsSync()) { + Future runForPackage(RepositoryPackage package) async { + if (package.directory.basename.endsWith('_platform_interface') && + !package.getSingleExampleDeprecated().directory.existsSync()) { // Platform interface packages generally aren't intended to have // examples, and don't need integration tests, so skip rather than fail. return PackageResult.skip( @@ -140,16 +141,16 @@ class DriveExamplesCommand extends PackageLoopingCommand { // If there is no supported target platform, skip the plugin. if (deviceFlags.isEmpty) { return PackageResult.skip( - '${getPackageDescription(package)} does not support any requested platform.'); + '${package.displayName} does not support any requested platform.'); } int examplesFound = 0; bool testsRan = false; final List errors = []; - for (final Directory example in getExamplesForPlugin(package)) { + for (final RepositoryPackage example in package.getExamples()) { ++examplesFound; final String exampleName = - getRelativePosixPath(example, from: packagesDir); + getRelativePosixPath(example.directory, from: packagesDir); final List drivers = await _getDrivers(example); if (drivers.isEmpty) { @@ -173,7 +174,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { if (testTargets.isEmpty) { final String driverRelativePath = - getRelativePosixPath(driver, from: package); + getRelativePosixPath(driver, from: package.directory); printError( 'Found $driverRelativePath, but no integration_test/*_test.dart files.'); errors.add('No test files for $driverRelativePath'); @@ -185,7 +186,8 @@ class DriveExamplesCommand extends PackageLoopingCommand { example, driver, testTargets, deviceFlags: deviceFlags); for (final File failingTarget in failingTargets) { - errors.add(getRelativePosixPath(failingTarget, from: package)); + errors.add( + getRelativePosixPath(failingTarget, from: package.directory)); } } } @@ -229,10 +231,10 @@ class DriveExamplesCommand extends PackageLoopingCommand { return deviceIds; } - Future> _getDrivers(Directory example) async { + Future> _getDrivers(RepositoryPackage example) async { final List drivers = []; - final Directory driverDir = example.childDirectory('test_driver'); + final Directory driverDir = example.directory.childDirectory('test_driver'); if (driverDir.existsSync()) { await for (final FileSystemEntity driver in driverDir.list()) { if (driver is File && driver.basename.endsWith('_test.dart')) { @@ -253,10 +255,10 @@ class DriveExamplesCommand extends PackageLoopingCommand { return testFile.existsSync() ? testFile : null; } - Future> _getIntegrationTests(Directory example) async { + Future> _getIntegrationTests(RepositoryPackage example) async { final List tests = []; final Directory integrationTestDir = - example.childDirectory('integration_test'); + example.directory.childDirectory('integration_test'); if (integrationTestDir.existsSync()) { await for (final FileSystemEntity file in integrationTestDir.list()) { @@ -278,7 +280,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { /// - `['-d', 'web-server', '--web-port=', '--browser-name=]` /// for web Future> _driveTests( - Directory example, + RepositoryPackage example, File driver, List targets, { required List deviceFlags, @@ -296,11 +298,11 @@ class DriveExamplesCommand extends PackageLoopingCommand { if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', '--driver', - getRelativePosixPath(driver, from: example), + getRelativePosixPath(driver, from: example.directory), '--target', - getRelativePosixPath(target, from: example), + getRelativePosixPath(target, from: example.directory), ], - workingDir: example); + workingDir: example.directory); if (exitCode != 0) { failures.add(target); } diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index fd2de97be4b3..4fc47c0da70c 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -13,6 +13,7 @@ import 'common/core.dart'; import 'common/gradle.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; const int _exitGcloudAuthFailed = 2; @@ -117,13 +118,13 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { - final Directory exampleDirectory = package.childDirectory('example'); + Future runForPackage(RepositoryPackage package) async { + final RepositoryPackage example = package.getSingleExampleDeprecated(); final Directory androidDirectory = - exampleDirectory.childDirectory('android'); + example.directory.childDirectory('android'); if (!androidDirectory.existsSync()) { return PackageResult.skip( - '${getPackageDescription(exampleDirectory)} does not support Android.'); + '${example.displayName} does not support Android.'); } if (!androidDirectory @@ -137,7 +138,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { } // Ensures that gradle wrapper exists - final GradleProject project = GradleProject(exampleDirectory, + final GradleProject project = GradleProject(example.directory, processRunner: processRunner, platform: platform); if (!await _ensureGradleWrapperExists(project)) { return PackageResult.fail(['Unable to build example apk']); @@ -155,7 +156,8 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { // test file's run. int resultsCounter = 0; for (final File test in _findIntegrationTestFiles(package)) { - final String testName = getRelativePosixPath(test, from: package); + final String testName = + getRelativePosixPath(test, from: package.directory); print('Testing $testName...'); if (!await _runGradle(project, 'app:assembleDebug', testFile: test)) { printError('Could not build $testName'); @@ -165,7 +167,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { final String buildId = getStringArg('build-id'); final String testRunId = getStringArg('test-run-id'); final String resultsDir = - 'plugins_android_test/${getPackageDescription(package)}/$buildId/$testRunId/${resultsCounter++}/'; + 'plugins_android_test/${package.displayName}/$buildId/$testRunId/${resultsCounter++}/'; final List args = [ 'firebase', 'test', @@ -186,7 +188,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { args.addAll(['--device', device]); } final int exitCode = await processRunner.runAndStream('gcloud', args, - workingDir: exampleDirectory); + workingDir: example.directory); if (exitCode != 0) { printError('Test failure for $testName'); @@ -262,9 +264,11 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { } /// Finds and returns all integration test files for [package]. - Iterable _findIntegrationTestFiles(Directory package) sync* { - final Directory integrationTestDir = - package.childDirectory('example').childDirectory('integration_test'); + Iterable _findIntegrationTestFiles(RepositoryPackage package) sync* { + final Directory integrationTestDir = package + .getSingleExampleDeprecated() + .directory + .childDirectory('integration_test'); if (!integrationTestDir.existsSync()) { return; diff --git a/script/tool/lib/src/lint_android_command.dart b/script/tool/lib/src/lint_android_command.dart index be6c6ed32415..a7b5c4f2e8bf 100644 --- a/script/tool/lib/src/lint_android_command.dart +++ b/script/tool/lib/src/lint_android_command.dart @@ -10,6 +10,7 @@ import 'common/core.dart'; import 'common/gradle.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; /// Lint the CocoaPod podspecs and run unit tests. /// @@ -30,22 +31,22 @@ class LintAndroidCommand extends PackageLoopingCommand { 'Requires the example to have been build at least once before running.'; @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { if (!pluginSupportsPlatform(kPlatformAndroid, package, requiredMode: PlatformSupport.inline)) { return PackageResult.skip( 'Plugin does not have an Android implemenatation.'); } - final Directory exampleDirectory = package.childDirectory('example'); - final GradleProject project = GradleProject(exampleDirectory, + final RepositoryPackage example = package.getSingleExampleDeprecated(); + final GradleProject project = GradleProject(example.directory, processRunner: processRunner, platform: platform); if (!project.isConfigured()) { return PackageResult.fail(['Build example before linting']); } - final String packageName = package.basename; + final String packageName = package.directory.basename; // Only lint one build mode to avoid extra work. // Only lint the plugin project itself, to avoid failing due to errors in diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart index d0d93fcb79b1..ee44a82da5b9 100644 --- a/script/tool/lib/src/lint_podspecs_command.dart +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -12,6 +12,7 @@ import 'package:platform/platform.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; const int _exitUnsupportedPlatform = 2; const int _exitPodNotInstalled = 3; @@ -64,7 +65,7 @@ class LintPodspecsCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { final List errors = []; final List podspecs = await _podspecsToLint(package); @@ -82,7 +83,7 @@ class LintPodspecsCommand extends PackageLoopingCommand { : PackageResult.fail(errors); } - Future> _podspecsToLint(Directory package) async { + Future> _podspecsToLint(RepositoryPackage package) async { final List podspecs = await getFilesForPackage(package).where((File entity) { final String filePath = entity.path; diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart index 29a8ceb12782..e45c09bfd2ef 100644 --- a/script/tool/lib/src/list_command.dart +++ b/script/tool/lib/src/list_command.dart @@ -6,6 +6,7 @@ import 'package:file/file.dart'; import 'package:platform/platform.dart'; import 'common/plugin_command.dart'; +import 'common/repository_package.dart'; /// A command to list different types of repository content. class ListCommand extends PluginCommand { @@ -39,23 +40,22 @@ class ListCommand extends PluginCommand { Future run() async { switch (getStringArg(_type)) { case _plugin: - await for (final PackageEnumerationEntry package - in getTargetPackages()) { - print(package.directory.path); + await for (final PackageEnumerationEntry entry in getTargetPackages()) { + print(entry.package.path); } break; case _example: - final Stream examples = getTargetPackages() - .map((PackageEnumerationEntry entry) => entry.directory) - .expand(getExamplesForPlugin); - await for (final Directory package in examples) { + final Stream examples = getTargetPackages() + .expand( + (PackageEnumerationEntry entry) => entry.package.getExamples()); + await for (final RepositoryPackage package in examples) { print(package.path); } break; case _package: - await for (final PackageEnumerationEntry package + await for (final PackageEnumerationEntry entry in getTargetPackagesAndSubpackages()) { - print(package.directory.path); + print(entry.package.path); } break; case _file: diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 0bd2ab45f634..725cf23a2e9a 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -10,6 +10,7 @@ import 'common/gradle.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; import 'common/xcode.dart'; const String _unitTestFlag = 'unit'; @@ -115,7 +116,7 @@ this command. } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { final List testPlatforms = []; for (final String platform in _requestedPlatforms) { if (pluginSupportsPlatform(platform, package, @@ -171,23 +172,24 @@ this command. : PackageResult.success(); } - Future<_PlatformResult> _testAndroid(Directory plugin, _TestMode mode) async { - bool exampleHasUnitTests(Directory example) { - return example + Future<_PlatformResult> _testAndroid( + RepositoryPackage plugin, _TestMode mode) async { + bool exampleHasUnitTests(RepositoryPackage example) { + return example.directory .childDirectory('android') .childDirectory('app') .childDirectory('src') .childDirectory('test') .existsSync() || - example.parent + example.directory.parent .childDirectory('android') .childDirectory('src') .childDirectory('test') .existsSync(); } - bool exampleHasNativeIntegrationTests(Directory example) { - final Directory integrationTestDirectory = example + bool exampleHasNativeIntegrationTests(RepositoryPackage example) { + final Directory integrationTestDirectory = example.directory .childDirectory('android') .childDirectory('app') .childDirectory('src') @@ -216,12 +218,12 @@ this command. }); } - final Iterable examples = getExamplesForPlugin(plugin); + final Iterable examples = plugin.getExamples(); bool ranTests = false; bool failed = false; bool hasMissingBuild = false; - for (final Directory example in examples) { + for (final RepositoryPackage example in examples) { final bool hasUnitTests = exampleHasUnitTests(example); final bool hasIntegrationTests = exampleHasNativeIntegrationTests(example); @@ -239,11 +241,11 @@ this command. continue; } - final String exampleName = getPackageDescription(example); + final String exampleName = example.displayName; _printRunningExampleTestsMessage(example, 'Android'); final GradleProject project = GradleProject( - example, + example.directory, processRunner: processRunner, platform: platform, ); @@ -301,12 +303,12 @@ this command. return _PlatformResult(RunState.succeeded); } - Future<_PlatformResult> _testIos(Directory plugin, _TestMode mode) { + Future<_PlatformResult> _testIos(RepositoryPackage plugin, _TestMode mode) { return _runXcodeTests(plugin, 'iOS', mode, extraFlags: _iosDestinationFlags); } - Future<_PlatformResult> _testMacOS(Directory plugin, _TestMode mode) { + Future<_PlatformResult> _testMacOS(RepositoryPackage plugin, _TestMode mode) { return _runXcodeTests(plugin, 'macOS', mode); } @@ -316,7 +318,7 @@ this command. /// The tests targets must be added to the Xcode project of the example app, /// usually at "example/{ios,macos}/Runner.xcworkspace". Future<_PlatformResult> _runXcodeTests( - Directory plugin, + RepositoryPackage plugin, String platform, _TestMode mode, { List extraFlags = const [], @@ -330,11 +332,11 @@ this command. // Assume skipped until at least one test has run. RunState overallResult = RunState.skipped; - for (final Directory example in getExamplesForPlugin(plugin)) { - final String exampleName = getPackageDescription(example); + for (final RepositoryPackage example in plugin.getExamples()) { + final String exampleName = example.displayName; if (testTarget != null) { - final Directory project = example + final Directory project = example.directory .childDirectory(platform.toLowerCase()) .childDirectory('Runner.xcodeproj'); final bool? hasTarget = @@ -351,7 +353,7 @@ this command. _printRunningExampleTestsMessage(example, platform); final int exitCode = await _xcode.runXcodeBuild( - example, + example.directory, actions: ['test'], workspace: '${platform.toLowerCase()}/Runner.xcworkspace', scheme: 'Runner', @@ -387,20 +389,22 @@ this command. /// Prints a standard format message indicating that [platform] tests for /// [plugin]'s [example] are about to be run. - void _printRunningExampleTestsMessage(Directory example, String platform) { - print('Running $platform tests for ${getPackageDescription(example)}...'); + void _printRunningExampleTestsMessage( + RepositoryPackage example, String platform) { + print('Running $platform tests for ${example.displayName}...'); } /// Prints a standard format message indicating that no tests were found for /// [plugin]'s [example] for [platform]. - void _printNoExampleTestsMessage(Directory example, String platform) { - print('No $platform tests found for ${getPackageDescription(example)}'); + void _printNoExampleTestsMessage(RepositoryPackage example, String platform) { + print('No $platform tests found for ${example.displayName}'); } } // The type for a function that takes a plugin directory and runs its native // tests for a specific platform. -typedef _TestFunction = Future<_PlatformResult> Function(Directory, _TestMode); +typedef _TestFunction = Future<_PlatformResult> Function( + RepositoryPackage, _TestMode); /// A collection of information related to a specific platform. class _PlatformDetails { diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index fda68a6a74a4..ab9f5f147495 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -16,6 +16,7 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; import 'common/pub_version_finder.dart'; +import 'common/repository_package.dart'; /// A command to check that packages are publishable via 'dart publish'. class PublishCheckCommand extends PackageLoopingCommand { @@ -75,7 +76,7 @@ class PublishCheckCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { final _PublishCheckResult? result = await _passesPublishCheck(package); if (result == null) { return PackageResult.skip('Package is marked as unpublishable.'); @@ -114,8 +115,8 @@ class PublishCheckCommand extends PackageLoopingCommand { } } - Pubspec? _tryParsePubspec(Directory package) { - final File pubspecFile = package.childFile('pubspec.yaml'); + Pubspec? _tryParsePubspec(RepositoryPackage package) { + final File pubspecFile = package.pubspecFile; try { return Pubspec.parse(pubspecFile.readAsStringSync()); @@ -127,12 +128,12 @@ class PublishCheckCommand extends PackageLoopingCommand { } } - Future _hasValidPublishCheckRun(Directory package) async { + Future _hasValidPublishCheckRun(RepositoryPackage package) async { print('Running pub publish --dry-run:'); final io.Process process = await processRunner.start( flutterCommand, ['pub', 'publish', '--', '--dry-run'], - workingDirectory: package, + workingDirectory: package.directory, ); final StringBuffer outputBuffer = StringBuffer(); @@ -183,8 +184,9 @@ class PublishCheckCommand extends PackageLoopingCommand { /// Returns the result of the publish check, or null if the package is marked /// as unpublishable. - Future<_PublishCheckResult?> _passesPublishCheck(Directory package) async { - final String packageName = package.basename; + Future<_PublishCheckResult?> _passesPublishCheck( + RepositoryPackage package) async { + final String packageName = package.directory.basename; final Pubspec? pubspec = _tryParsePubspec(package); if (pubspec == null) { print('no pubspec'); @@ -219,7 +221,7 @@ class PublishCheckCommand extends PackageLoopingCommand { Future<_PublishCheckResult> _checkPublishingStatus( {required String packageName, required Version? version}) async { final PubVersionFinderResponse pubVersionFinderResponse = - await _pubVersionFinder.getPackageVersion(package: packageName); + await _pubVersionFinder.getPackageVersion(packageName: packageName); switch (pubVersionFinderResponse.result) { case PubVersionFinderResult.success: return pubVersionFinderResponse.versions.contains(version) diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 8bcb9e37e8ef..6e1658f6f6e2 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -140,9 +140,9 @@ class PublishPluginCommand extends PluginCommand { @override Future run() async { - final String package = getStringArg(_packageOption); + final String packageName = getStringArg(_packageOption); final bool publishAllChanged = getBoolArg(_allChangedFlag); - if (package.isEmpty && !publishAllChanged) { + if (packageName.isEmpty && !publishAllChanged) { _print( 'Must specify a package to publish. See `plugin_tools help publish-plugin`.'); throw ToolExit(1); @@ -176,7 +176,7 @@ class PublishPluginCommand extends PluginCommand { ); } else { successful = await _publishAndTagPackage( - packageDir: _getPackageDir(package), + packageDir: _getPackageDir(packageName), remoteForTagPush: remote, ); } @@ -202,7 +202,7 @@ class PublishPluginCommand extends PluginCommand { await baseGitDir.runCommand(['tag', '--sort=-committerdate']); final List existingTags = (existingTagsResult.stdout as String) .split('\n') - ..removeWhere((String element) => element.isEmpty); + ..removeWhere((String element) => element.isEmpty); final List packagesReleased = []; final List packagesFailed = []; @@ -307,7 +307,7 @@ Safe to ignore if the package is deleted in this commit. // Check if the package named `packageName` with `version` has already published. final Version version = pubspec.version!; final PubVersionFinderResponse pubVersionFinderResponse = - await _pubVersionFinder.getPackageVersion(package: pubspec.name); + await _pubVersionFinder.getPackageVersion(packageName: pubspec.name); if (pubVersionFinderResponse.versions.contains(version)) { final String tagsForPackageWithSameVersion = existingTags.firstWhere( (String tag) => @@ -390,8 +390,8 @@ Safe to ignore if the package is deleted in this commit. // Returns the packageDirectory based on the package name. // Throws ToolExit if the `package` doesn't exist. - Directory _getPackageDir(String package) { - final Directory packageDir = packagesDir.childDirectory(package); + Directory _getPackageDir(String packageName) { + final Directory packageDir = packagesDir.childDirectory(packageName); if (!packageDir.existsSync()) { _print('${packageDir.absolute.path} does not exist.'); throw ToolExit(1); diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 0a066ab72baf..def2adaf2788 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -10,6 +10,7 @@ import 'package:pubspec_parse/pubspec_parse.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; /// A command to enforce pubspec conventions across the repository. /// @@ -64,8 +65,8 @@ class PubspecCheckCommand extends PackageLoopingCommand { bool get includeSubpackages => true; @override - Future runForPackage(Directory package) async { - final File pubspec = package.childFile('pubspec.yaml'); + Future runForPackage(RepositoryPackage package) async { + final File pubspec = package.pubspecFile; final bool passesCheck = !pubspec.existsSync() || await _checkPubspec(pubspec, package: package); if (!passesCheck) { @@ -76,7 +77,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { Future _checkPubspec( File pubspecFile, { - required Directory package, + required RepositoryPackage package, }) async { final String contents = pubspecFile.readAsStringSync(); final Pubspec? pubspec = _tryParsePubspec(contents); @@ -154,7 +155,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { List _checkForRepositoryLinkErrors( Pubspec pubspec, { - required Directory package, + required RepositoryPackage package, }) { final List errorMessages = []; if (pubspec.repository == null) { @@ -189,12 +190,12 @@ class PubspecCheckCommand extends PackageLoopingCommand { // Should only be called on plugin packages. String? _checkForImplementsError( Pubspec pubspec, { - required Directory package, + required RepositoryPackage package, }) { if (_isImplementationPackage(package)) { final String? implements = pubspec.flutter!['plugin']!['implements'] as String?; - final String expectedImplements = package.parent.basename; + final String expectedImplements = package.directory.parent.basename; if (implements == null) { return 'Missing "implements: $expectedImplements" in "plugin" section.'; } else if (implements != expectedImplements) { @@ -207,13 +208,13 @@ class PubspecCheckCommand extends PackageLoopingCommand { // Returns true if [packageName] appears to be an implementation package // according to repository conventions. - bool _isImplementationPackage(Directory package) { + bool _isImplementationPackage(RepositoryPackage package) { // An implementation package should be in a group folder... - final Directory parentDir = package.parent; + final Directory parentDir = package.directory.parent; if (parentDir.path == packagesDir.path) { return false; } - final String packageName = package.basename; + final String packageName = package.directory.basename; final String parentName = parentDir.basename; // ... whose name is a prefix of the package name. if (!packageName.startsWith(parentName)) { diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart index 9dfe66b7926a..5a0b43d3b223 100644 --- a/script/tool/lib/src/test_command.dart +++ b/script/tool/lib/src/test_command.dart @@ -9,6 +9,7 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; /// A command to run Dart unit tests for packages. class TestCommand extends PackageLoopingCommand { @@ -36,13 +37,13 @@ class TestCommand extends PackageLoopingCommand { 'This command requires "flutter" to be in your path.'; @override - Future runForPackage(Directory package) async { - if (!package.childDirectory('test').existsSync()) { + Future runForPackage(RepositoryPackage package) async { + if (!package.directory.childDirectory('test').existsSync()) { return PackageResult.skip('No test/ directory.'); } bool passed; - if (isFlutterPackage(package)) { + if (isFlutterPackage(package.directory)) { passed = await _runFlutterTests(package); } else { passed = await _runDartTests(package); @@ -51,7 +52,7 @@ class TestCommand extends PackageLoopingCommand { } /// Runs the Dart tests for a Flutter package, returning true on success. - Future _runFlutterTests(Directory package) async { + Future _runFlutterTests(RepositoryPackage package) async { final String experiment = getStringArg(kEnableExperiment); final int exitCode = await processRunner.runAndStream( @@ -61,21 +62,21 @@ class TestCommand extends PackageLoopingCommand { '--color', if (experiment.isNotEmpty) '--enable-experiment=$experiment', // TODO(ditman): Remove this once all plugins are migrated to 'drive'. - if (isWebPlugin(package)) '--platform=chrome', + if (pluginSupportsPlatform(kPlatformWeb, package)) '--platform=chrome', ], - workingDir: package, + workingDir: package.directory, ); return exitCode == 0; } /// Runs the Dart tests for a non-Flutter package, returning true on success. - Future _runDartTests(Directory package) async { + Future _runDartTests(RepositoryPackage package) async { // Unlike `flutter test`, `pub run test` does not automatically get // packages int exitCode = await processRunner.runAndStream( 'dart', ['pub', 'get'], - workingDir: package, + workingDir: package.directory, ); if (exitCode != 0) { printError('Unable to fetch dependencies.'); @@ -92,7 +93,7 @@ class TestCommand extends PackageLoopingCommand { if (experiment.isNotEmpty) '--enable-experiment=$experiment', 'test', ], - workingDir: package, + workingDir: package.directory, ); return exitCode == 0; diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 67c563782888..67a81b967a8e 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -16,6 +16,7 @@ import 'common/git_version_finder.dart'; import 'common/package_looping_command.dart'; import 'common/process_runner.dart'; import 'common/pub_version_finder.dart'; +import 'common/repository_package.dart'; /// Categories of version change types. enum NextVersionType { @@ -133,7 +134,7 @@ class VersionCheckCommand extends PackageLoopingCommand { Future initializeRun() async {} @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { final Pubspec? pubspec = _tryParsePubspec(package); if (pubspec == null) { // No remaining checks make sense, so fail immediately. @@ -196,7 +197,7 @@ class VersionCheckCommand extends PackageLoopingCommand { /// the name from pubspec.yaml, not the on disk name if different.) Future _fetchPreviousVersionFromPub(String packageName) async { final PubVersionFinderResponse pubVersionFinderResponse = - await _pubVersionFinder.getPackageVersion(package: packageName); + await _pubVersionFinder.getPackageVersion(packageName: packageName); switch (pubVersionFinderResponse.result) { case PubVersionFinderResult.success: return pubVersionFinderResponse.versions.first; @@ -214,10 +215,10 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} /// Returns the version of [package] from git at the base comparison hash. Future _getPreviousVersionFromGit( - Directory package, { + RepositoryPackage package, { required GitVersionFinder gitVersionFinder, }) async { - final File pubspecFile = package.childFile('pubspec.yaml'); + final File pubspecFile = package.pubspecFile; final String relativePath = path.relative(pubspecFile.absolute.path, from: (await gitDir).path); // Use Posix-style paths for git. @@ -230,7 +231,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} /// Returns the state of the verison of [package] relative to the comparison /// base (git or pub, depending on flags). Future<_CurrentVersionState> _getVersionState( - Directory package, { + RepositoryPackage package, { required Pubspec pubspec, }) async { // This method isn't called unless `version` is non-null. @@ -310,7 +311,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} /// /// Returns false if the CHANGELOG fails validation. Future _validateChangelogVersion( - Directory package, { + RepositoryPackage package, { required Pubspec pubspec, required bool pubspecVersionChanged, }) async { @@ -318,7 +319,7 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} final Version fromPubspec = pubspec.version!; // get first version from CHANGELOG - final File changelog = package.childFile('CHANGELOG.md'); + final File changelog = package.directory.childFile('CHANGELOG.md'); final List lines = changelog.readAsLinesSync(); String? firstLineWithText; final Iterator iterator = lines.iterator; @@ -386,8 +387,8 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog. return true; } - Pubspec? _tryParsePubspec(Directory package) { - final File pubspecFile = package.childFile('pubspec.yaml'); + Pubspec? _tryParsePubspec(RepositoryPackage package) { + final File pubspecFile = package.pubspecFile; try { final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); diff --git a/script/tool/lib/src/xcode_analyze_command.dart b/script/tool/lib/src/xcode_analyze_command.dart index 27cd8c435142..3d34dab9f087 100644 --- a/script/tool/lib/src/xcode_analyze_command.dart +++ b/script/tool/lib/src/xcode_analyze_command.dart @@ -9,6 +9,7 @@ import 'common/core.dart'; import 'common/package_looping_command.dart'; import 'common/plugin_utils.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; import 'common/xcode.dart'; /// The command to run Xcode's static analyzer on plugins. @@ -42,7 +43,7 @@ class XcodeAnalyzeCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { final bool testIos = getBoolArg(kPlatformIos) && pluginSupportsPlatform(kPlatformIos, package, requiredMode: PlatformSupport.inline); @@ -78,18 +79,18 @@ class XcodeAnalyzeCommand extends PackageLoopingCommand { /// Analyzes [plugin] for [platform], returning true if it passed analysis. Future _analyzePlugin( - Directory plugin, + RepositoryPackage plugin, String platform, { List extraFlags = const [], }) async { bool passing = true; - for (final Directory example in getExamplesForPlugin(plugin)) { + for (final RepositoryPackage example in plugin.getExamples()) { // Running tests and static analyzer. - final String examplePath = - getRelativePosixPath(example, from: plugin.parent); + final String examplePath = getRelativePosixPath(example.directory, + from: plugin.directory.parent); print('Running $platform tests and analyzer for $examplePath...'); final int exitCode = await _xcode.runXcodeBuild( - example, + example.directory, actions: ['analyze'], workspace: '${platform.toLowerCase()}/Runner.xcworkspace', scheme: 'Runner', diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index 00e64ddc21fe..721923ae9c6e 100644 --- a/script/tool/test/common/package_looping_command_test.dart +++ b/script/tool/test/common/package_looping_command_test.dart @@ -11,6 +11,7 @@ import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/package_looping_command.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; import 'package:git/git.dart'; import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; @@ -578,64 +579,6 @@ void main() { ])); }); }); - - group('utility', () { - test('getPackageDescription prints packageDir-relative paths by default', - () async { - final TestPackageLoopingCommand command = - TestPackageLoopingCommand(packagesDir, platform: mockPlatform); - - expect( - command.getPackageDescription(packagesDir.childDirectory('foo')), - 'foo', - ); - expect( - command.getPackageDescription(packagesDir - .childDirectory('foo') - .childDirectory('bar') - .childDirectory('baz')), - 'foo/bar/baz', - ); - }); - - test('getPackageDescription always uses Posix-style paths', () async { - mockPlatform.isWindows = true; - final TestPackageLoopingCommand command = - TestPackageLoopingCommand(packagesDir, platform: mockPlatform); - - expect( - command.getPackageDescription(packagesDir.childDirectory('foo')), - 'foo', - ); - expect( - command.getPackageDescription(packagesDir - .childDirectory('foo') - .childDirectory('bar') - .childDirectory('baz')), - 'foo/bar/baz', - ); - }); - - test( - 'getPackageDescription elides group name in grouped federated plugin structure', - () async { - final TestPackageLoopingCommand command = - TestPackageLoopingCommand(packagesDir, platform: mockPlatform); - - expect( - command.getPackageDescription(packagesDir - .childDirectory('a_plugin') - .childDirectory('a_plugin_platform_interface')), - 'a_plugin_platform_interface', - ); - expect( - command.getPackageDescription(packagesDir - .childDirectory('a_plugin') - .childDirectory('a_plugin_web')), - 'a_plugin_web', - ); - }); - }); } class TestPackageLoopingCommand extends PackageLoopingCommand { @@ -699,18 +642,18 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + Future runForPackage(RepositoryPackage package) async { checkedPackages.add(package.path); - final File warningFile = package.childFile(_warningFile); + final File warningFile = package.directory.childFile(_warningFile); if (warningFile.existsSync()) { final List warnings = warningFile.readAsLinesSync(); warnings.forEach(logWarning); } - final File skipFile = package.childFile(_skipFile); + final File skipFile = package.directory.childFile(_skipFile); if (skipFile.existsSync()) { return PackageResult.skip(skipFile.readAsStringSync()); } - final File errorFile = package.childFile(_errorFile); + final File errorFile = package.directory.childFile(_errorFile); if (errorFile.existsSync()) { return PackageResult.fail(errorFile.readAsLinesSync()); } diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 2f332aa8eb55..10bdff4e9c56 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -498,7 +498,7 @@ packages/plugin3/plugin3.dart expect( localCommand.plugins, unorderedEquals(expectedShards[i] - .map((Directory package) => package.path) + .map((Directory packageDir) => packageDir.path) .toList())); } }); @@ -541,7 +541,7 @@ packages/plugin3/plugin3.dart expect( localCommand.plugins, unorderedEquals(expectedShards[i] - .map((Directory package) => package.path) + .map((Directory packageDir) => packageDir.path) .toList())); } }); @@ -594,7 +594,7 @@ packages/plugin3/plugin3.dart expect( localCommand.plugins, unorderedEquals(expectedShards[i] - .map((Directory package) => package.path) + .map((Directory packageDir) => packageDir.path) .toList())); } }); @@ -620,8 +620,8 @@ class SamplePluginCommand extends PluginCommand { @override Future run() async { - await for (final PackageEnumerationEntry package in getTargetPackages()) { - plugins.add(package.directory.path); + await for (final PackageEnumerationEntry entry in getTargetPackages()) { + plugins.add(entry.package.path); } } } diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart index c32c3f8e02bf..7f1ba2add00a 100644 --- a/script/tool/test/common/plugin_utils_test.dart +++ b/script/tool/test/common/plugin_utils_test.dart @@ -6,6 +6,7 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; import 'package:test/test.dart'; import '../util.dart'; @@ -21,7 +22,8 @@ void main() { group('pluginSupportsPlatform', () { test('no platforms', () async { - final Directory plugin = createFakePlugin('plugin', packagesDir); + final RepositoryPackage plugin = + RepositoryPackage(createFakePlugin('plugin', packagesDir)); expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isFalse); expect(pluginSupportsPlatform(kPlatformIos, plugin), isFalse); @@ -32,7 +34,8 @@ void main() { }); test('all platforms', () async { - final Directory plugin = createFakePlugin('plugin', packagesDir, + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', packagesDir, platformSupport: { kPlatformAndroid: PlatformSupport.inline, kPlatformIos: PlatformSupport.inline, @@ -40,7 +43,7 @@ void main() { kPlatformMacos: PlatformSupport.inline, kPlatformWeb: PlatformSupport.inline, kPlatformWindows: PlatformSupport.inline, - }); + })); expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isTrue); expect(pluginSupportsPlatform(kPlatformIos, plugin), isTrue); @@ -51,7 +54,7 @@ void main() { }); test('some platforms', () async { - final Directory plugin = createFakePlugin( + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( 'plugin', packagesDir, platformSupport: { @@ -59,7 +62,7 @@ void main() { kPlatformLinux: PlatformSupport.inline, kPlatformWeb: PlatformSupport.inline, }, - ); + )); expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isTrue); expect(pluginSupportsPlatform(kPlatformIos, plugin), isFalse); @@ -70,7 +73,7 @@ void main() { }); test('inline plugins are only detected as inline', () async { - final Directory plugin = createFakePlugin( + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( 'plugin', packagesDir, platformSupport: { @@ -81,7 +84,7 @@ void main() { kPlatformWeb: PlatformSupport.inline, kPlatformWindows: PlatformSupport.inline, }, - ); + )); expect( pluginSupportsPlatform(kPlatformAndroid, plugin, @@ -135,7 +138,7 @@ void main() { test('federated plugins are only detected as federated', () async { const String pluginName = 'plugin'; - final Directory plugin = createFakePlugin( + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( pluginName, packagesDir, platformSupport: { @@ -146,7 +149,7 @@ void main() { kPlatformWeb: PlatformSupport.federated, kPlatformWindows: PlatformSupport.federated, }, - ); + )); expect( pluginSupportsPlatform(kPlatformAndroid, plugin, diff --git a/script/tool/test/common/pub_version_finder_test.dart b/script/tool/test/common/pub_version_finder_test.dart index 7d8658a907ee..1692cf214abe 100644 --- a/script/tool/test/common/pub_version_finder_test.dart +++ b/script/tool/test/common/pub_version_finder_test.dart @@ -19,7 +19,7 @@ void main() { }); final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); final PubVersionFinderResponse response = - await finder.getPackageVersion(package: 'some_package'); + await finder.getPackageVersion(packageName: 'some_package'); expect(response.versions, isEmpty); expect(response.result, PubVersionFinderResult.noPackageFound); @@ -33,7 +33,7 @@ void main() { }); final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); final PubVersionFinderResponse response = - await finder.getPackageVersion(package: 'some_package'); + await finder.getPackageVersion(packageName: 'some_package'); expect(response.versions, isEmpty); expect(response.result, PubVersionFinderResult.fail); @@ -64,7 +64,7 @@ void main() { }); final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); final PubVersionFinderResponse response = - await finder.getPackageVersion(package: 'some_package'); + await finder.getPackageVersion(packageName: 'some_package'); expect(response.versions, [ Version.parse('2.0.0'), diff --git a/script/tool/test/common/repository_package_test.dart b/script/tool/test/common/repository_package_test.dart new file mode 100644 index 000000000000..5c5624312f51 --- /dev/null +++ b/script/tool/test/common/repository_package_test.dart @@ -0,0 +1,123 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; +import 'package:test/test.dart'; + +import '../util.dart'; + +void main() { + late FileSystem fileSystem; + late Directory packagesDir; + + setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + }); + + group('displayName', () { + test('prints packageDir-relative paths by default', () async { + expect( + RepositoryPackage(packagesDir.childDirectory('foo')).displayName, + 'foo', + ); + expect( + RepositoryPackage(packagesDir + .childDirectory('foo') + .childDirectory('bar') + .childDirectory('baz')) + .displayName, + 'foo/bar/baz', + ); + }); + + test('handles third_party/packages/', () async { + expect( + RepositoryPackage(packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages') + .childDirectory('foo') + .childDirectory('bar') + .childDirectory('baz')) + .displayName, + 'foo/bar/baz', + ); + }); + + test('always uses Posix-style paths', () async { + final Directory windowsPackagesDir = createPackagesDirectory( + fileSystem: MemoryFileSystem(style: FileSystemStyle.windows)); + + expect( + RepositoryPackage(windowsPackagesDir.childDirectory('foo')).displayName, + 'foo', + ); + expect( + RepositoryPackage(windowsPackagesDir + .childDirectory('foo') + .childDirectory('bar') + .childDirectory('baz')) + .displayName, + 'foo/bar/baz', + ); + }); + + test('elides group name in grouped federated plugin structure', () async { + expect( + RepositoryPackage(packagesDir + .childDirectory('a_plugin') + .childDirectory('a_plugin_platform_interface')) + .displayName, + 'a_plugin_platform_interface', + ); + expect( + RepositoryPackage(packagesDir + .childDirectory('a_plugin') + .childDirectory('a_plugin_platform_web')) + .displayName, + 'a_plugin_platform_web', + ); + }); + + // The app-facing package doesn't get elided to avoid potential confusion + // with the group folder itself. + test('does not elide group name for app-facing packages', () async { + expect( + RepositoryPackage(packagesDir + .childDirectory('a_plugin') + .childDirectory('a_plugin')) + .displayName, + 'a_plugin/a_plugin', + ); + }); + }); + + group('getExamples', () { + test('handles a single example', () async { + final Directory plugin = createFakePlugin('a_plugin', packagesDir); + + final List examples = + RepositoryPackage(plugin).getExamples().toList(); + + expect(examples.length, 1); + expect(examples[0].path, plugin.childDirectory('example').path); + }); + + test('handles multiple examples', () async { + final Directory plugin = createFakePlugin('a_plugin', packagesDir, + examples: ['example1', 'example2']); + + final List examples = + RepositoryPackage(plugin).getExamples().toList(); + + expect(examples.length, 2); + expect(examples[0].path, + plugin.childDirectory('example').childDirectory('example1').path); + expect(examples[1].path, + plugin.childDirectory('example').childDirectory('example2').path); + }); + }); +} diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index cf57a9d0dcf7..e2bf1e3e6e8e 100644 --- a/script/tool/test/format_command_test.dart +++ b/script/tool/test/format_command_test.dart @@ -49,10 +49,10 @@ void main() { /// Returns a modified version of a list of [relativePaths] that are relative /// to [package] to instead be relative to [packagesDir]. List _getPackagesDirRelativePaths( - Directory package, List relativePaths) { + Directory packageDir, List relativePaths) { final p.Context path = analyzeCommand.path; final String relativeBase = - path.relative(package.path, from: packagesDir.path); + path.relative(packageDir.path, from: packagesDir.path); return relativePaths .map((String relativePath) => path.join(relativeBase, relativePath)) .toList(); diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 10a85f49e815..05aebe82fd79 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -46,6 +46,7 @@ Directory createPackagesDirectory( /// /// [extraFiles] is an optional list of plugin-relative paths, using Posix /// separators, of extra files to create in the plugin. +// TODO(stuartmorgan): Convert the return to a RepositoryPackage. Directory createFakePlugin( String name, Directory parentDirectory, { @@ -77,6 +78,7 @@ Directory createFakePlugin( /// /// [extraFiles] is an optional list of package-relative paths, using unix-style /// separators, of extra files to create in the package. +// TODO(stuartmorgan): Convert the return to a RepositoryPackage. Directory createFakePackage( String name, Directory parentDirectory, { From 729c3e4117e6d1a026b50f363b2a202352231fdd Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 24 Aug 2021 21:22:36 -0400 Subject: [PATCH 216/364] [flutter_plugin_tool] Migrate publish_plugin_command_test to runCapturingPrint (#4260) Finishes the migration of tool tests to `runCapturingPrint`. This makes the tests much less verbose, and makes it match the rest of the tool tests. Also adds the use of `printError` for error output, now that it's trivial to do so. --- .../tool/lib/src/publish_plugin_command.dart | 70 ++-- .../test/publish_plugin_command_test.dart | 355 ++++++++++-------- 2 files changed, 239 insertions(+), 186 deletions(-) diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 6e1658f6f6e2..5a75ce6af89f 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -11,6 +11,7 @@ import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:yaml/yaml.dart'; @@ -48,15 +49,15 @@ class PublishPluginCommand extends PluginCommand { PublishPluginCommand( Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), - Print print = print, + Platform platform = const LocalPlatform(), io.Stdin? stdinput, GitDir? gitDir, http.Client? httpClient, }) : _pubVersionFinder = PubVersionFinder(httpClient: httpClient ?? http.Client()), - _print = print, _stdin = stdinput ?? io.stdin, - super(packagesDir, processRunner: processRunner, gitDir: gitDir) { + super(packagesDir, + platform: platform, processRunner: processRunner, gitDir: gitDir) { argParser.addOption( _packageOption, help: 'The package to publish.' @@ -133,7 +134,6 @@ class PublishPluginCommand extends PluginCommand { 'If running this on CI, an environment variable named $_pubCredentialName must be set to a String that represents the pub credential JSON.\n' 'WARNING: Do not check in the content of pub credential JSON, it should only come from secure sources.'; - final Print _print; final io.Stdin _stdin; StreamSubscription? _stdinSubscription; final PubVersionFinder _pubVersionFinder; @@ -143,12 +143,12 @@ class PublishPluginCommand extends PluginCommand { final String packageName = getStringArg(_packageOption); final bool publishAllChanged = getBoolArg(_allChangedFlag); if (packageName.isEmpty && !publishAllChanged) { - _print( + printError( 'Must specify a package to publish. See `plugin_tools help publish-plugin`.'); throw ToolExit(1); } - _print('Checking local repo...'); + print('Checking local repo...'); final GitDir repository = await gitDir; final bool shouldPushTag = getBoolArg(_pushTagsOption); @@ -163,9 +163,9 @@ class PublishPluginCommand extends PluginCommand { } remote = _RemoteInfo(name: remoteName, url: remoteUrl); } - _print('Local repo is ready!'); + print('Local repo is ready!'); if (getBoolArg(_dryRunFlag)) { - _print('=============== DRY RUN ==============='); + print('=============== DRY RUN ==============='); } bool successful; @@ -193,11 +193,11 @@ class PublishPluginCommand extends PluginCommand { final List changedPubspecs = await gitVersionFinder.getChangedPubSpecs(); if (changedPubspecs.isEmpty) { - _print('No version updates in this commit.'); + print('No version updates in this commit.'); return true; } - _print('Getting existing tags...'); + print('Getting existing tags...'); final io.ProcessResult existingTagsResult = await baseGitDir.runCommand(['tag', '--sort=-committerdate']); final List existingTags = (existingTagsResult.stdout as String) @@ -228,7 +228,7 @@ class PublishPluginCommand extends PluginCommand { packagesFailed.add(pubspecFile.parent.basename); continue; } - _print('\n'); + print('\n'); if (await _publishAndTagPackage( packageDir: pubspecFile.parent, remoteForTagPush: remoteForTagPush, @@ -237,13 +237,13 @@ class PublishPluginCommand extends PluginCommand { } else { packagesFailed.add(pubspecFile.parent.basename); } - _print('\n'); + print('\n'); } if (packagesReleased.isNotEmpty) { - _print('Packages released: ${packagesReleased.join(', ')}'); + print('Packages released: ${packagesReleased.join(', ')}'); } if (packagesFailed.isNotEmpty) { - _print( + printError( 'Failed to release the following packages: ${packagesFailed.join(', ')}, see above for details.'); } return packagesFailed.isEmpty; @@ -268,7 +268,7 @@ class PublishPluginCommand extends PluginCommand { return false; } } - _print('Released [${packageDir.basename}] successfully.'); + print('Released [${packageDir.basename}] successfully.'); return true; } @@ -278,7 +278,7 @@ class PublishPluginCommand extends PluginCommand { required List existingTags, }) async { if (!pubspecFile.existsSync()) { - _print(''' + print(''' The file at The pubspec file at ${pubspecFile.path} does not exist. Publishing will not happen for ${pubspecFile.parent.basename}. Safe to ignore if the package is deleted in this commit. '''); @@ -299,7 +299,7 @@ Safe to ignore if the package is deleted in this commit. } if (pubspec.version == null) { - _print( + printError( 'No version found. A package that intentionally has no version should be marked "publish_to: none"'); return _CheckNeedsReleaseResult.failure; } @@ -314,14 +314,14 @@ Safe to ignore if the package is deleted in this commit. tag.split('-v').first == pubspec.name && tag.split('-v').last == version.toString(), orElse: () => ''); - _print( + print( 'The version $version of ${pubspec.name} has already been published'); if (tagsForPackageWithSameVersion.isEmpty) { - _print( + printError( 'However, the git release tag for this version (${pubspec.name}-v$version) is not found. Please manually fix the tag then run the command again.'); return _CheckNeedsReleaseResult.failure; } else { - _print('skip.'); + print('skip.'); return _CheckNeedsReleaseResult.noRelease; } } @@ -340,7 +340,7 @@ Safe to ignore if the package is deleted in this commit. if (!publishOK) { return false; } - _print('Package published!'); + print('Package published!'); return true; } @@ -353,7 +353,7 @@ Safe to ignore if the package is deleted in this commit. _RemoteInfo? remoteForPush, }) async { final String tag = _getTag(packageDir); - _print('Tagging release $tag...'); + print('Tagging release $tag...'); if (!getBoolArg(_dryRunFlag)) { final io.ProcessResult result = await processRunner.run( 'git', @@ -370,7 +370,7 @@ Safe to ignore if the package is deleted in this commit. return true; } - _print('Pushing tag to ${remoteForPush.name}...'); + print('Pushing tag to ${remoteForPush.name}...'); return await _pushTagToRemote( tag: tag, remote: remoteForPush, @@ -381,9 +381,9 @@ Safe to ignore if the package is deleted in this commit. await _stdinSubscription?.cancel(); _stdinSubscription = null; if (successful) { - _print('Done!'); + print('Done!'); } else { - _print('Failed, see above for details.'); + printError('Failed, see above for details.'); throw ToolExit(1); } } @@ -393,7 +393,7 @@ Safe to ignore if the package is deleted in this commit. Directory _getPackageDir(String packageName) { final Directory packageDir = packagesDir.childDirectory(packageName); if (!packageDir.existsSync()) { - _print('${packageDir.absolute.path} does not exist.'); + printError('${packageDir.absolute.path} does not exist.'); throw ToolExit(1); } return packageDir; @@ -412,7 +412,7 @@ Safe to ignore if the package is deleted in this commit. final String statusOutput = statusResult.stdout as String; if (statusOutput.isNotEmpty) { - _print( + printError( "There are files in the package directory that haven't been saved in git. Refusing to publish these files:\n\n" '$statusOutput\n' 'If the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.'); @@ -435,7 +435,7 @@ Safe to ignore if the package is deleted in this commit. Future _publish(Directory packageDir) async { final List publishFlags = getStringListArg(_pubFlagsOption); - _print( + print( 'Running `pub publish ${publishFlags.join(' ')}` in ${packageDir.absolute.path}...\n'); if (getBoolArg(_dryRunFlag)) { return true; @@ -451,18 +451,14 @@ Safe to ignore if the package is deleted in this commit. final io.Process publish = await processRunner.start( flutterCommand, ['pub', 'publish'] + publishFlags, workingDirectory: packageDir); - publish.stdout - .transform(utf8.decoder) - .listen((String data) => _print(data)); - publish.stderr - .transform(utf8.decoder) - .listen((String data) => _print(data)); + publish.stdout.transform(utf8.decoder).listen((String data) => print(data)); + publish.stderr.transform(utf8.decoder).listen((String data) => print(data)); _stdinSubscription ??= _stdin .transform(utf8.decoder) .listen((String data) => publish.stdin.writeln(data)); final int result = await publish.exitCode; if (result != 0) { - _print('Publish ${packageDir.basename} failed.'); + printError('Publish ${packageDir.basename} failed.'); return false; } return true; @@ -490,10 +486,10 @@ Safe to ignore if the package is deleted in this commit. }) async { assert(remote != null && tag != null); if (!getBoolArg(_skipConfirmationFlag)) { - _print('Ready to push $tag to ${remote.url} (y/n)?'); + print('Ready to push $tag to ${remote.url} (y/n)?'); final String? input = _stdin.readLineSync(); if (input?.toLowerCase() != 'y') { - _print('Tag push canceled.'); + print('Tag push canceled.'); return false; } } diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 9a937daa2384..576d3a4c88c8 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -24,7 +24,6 @@ import 'util.dart'; void main() { const String testPluginName = 'foo'; - late List printedMessages; late Directory testRoot; late Directory packagesDir; @@ -62,13 +61,9 @@ void main() { await gitDir.runCommand(['commit', '-m', 'Initial commit']); processRunner = TestProcessRunner(); mockStdin = MockStdin(); - printedMessages = []; commandRunner = CommandRunner('tester', '') ..addCommand(PublishPluginCommand(packagesDir, - processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), - stdinput: mockStdin, - gitDir: gitDir)); + processRunner: processRunner, stdinput: mockStdin, gitDir: gitDir)); }); tearDown(() { @@ -77,50 +72,66 @@ void main() { group('Initial validation', () { test('requires a package flag', () async { - await expectLater(() => commandRunner.run(['publish-plugin']), - throwsA(isA())); - expect( - printedMessages.last, contains('Must specify a package to publish.')); + Error? commandError; + final List output = await runCapturingPrint( + commandRunner, ['publish-plugin'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect(output.last, contains('Must specify a package to publish.')); }); test('requires an existing flag', () async { - await expectLater( - () => commandRunner.run([ - 'publish-plugin', - '--package', - 'iamerror', - '--no-push-tags' - ]), - throwsA(isA())); - - expect(printedMessages.last, contains('iamerror does not exist')); + Error? commandError; + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--package', 'iamerror', '--no-push-tags'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect(output.last, contains('iamerror does not exist')); }); test('refuses to proceed with dirty files', () async { pluginDir.childFile('tmp').createSync(); - await expectLater( - () => commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags' - ]), - throwsA(isA())); + Error? commandError; + final List output = await runCapturingPrint( + commandRunner, [ + 'publish-plugin', + '--package', + testPluginName, + '--no-push-tags' + ], errorHandler: (Error e) { + commandError = e; + }); + expect(commandError, isA()); expect( - printedMessages, - containsAllInOrder([ - 'There are files in the package directory that haven\'t been saved in git. Refusing to publish these files:\n\n?? packages/foo/tmp\n\nIf the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.', - 'Failed, see above for details.', + output, + containsAllInOrder([ + contains('There are files in the package directory that haven\'t ' + 'been saved in git. Refusing to publish these files:\n\n' + '?? packages/foo/tmp\n\n' + 'If the directory should be clean, you can run `git clean -xdf && ' + 'git reset --hard HEAD` to wipe all local changes.'), + contains('Failed, see above for details.'), ])); }); test('fails immediately if the remote doesn\'t exist', () async { - await expectLater( - () => commandRunner - .run(['publish-plugin', '--package', testPluginName]), - throwsA(isA())); + Error? commandError; + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--package', + testPluginName + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect(processRunner.results.last.stderr, contains('No such remote')); }); @@ -128,7 +139,8 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -136,7 +148,7 @@ void main() { '--no-tag-release' ]); - expect(printedMessages.last, 'Done!'); + expect(output.last, 'Done!'); }); test('can publish non-flutter package', () async { @@ -149,20 +161,28 @@ void main() { await gitDir.runCommand(['commit', '-m', 'Initial commit']); // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + + final List output = await runCapturingPrint( + commandRunner, [ 'publish-plugin', '--package', packageName, '--no-push-tags', '--no-tag-release' ]); - expect(printedMessages.last, 'Done!'); + + expect(output.last, 'Done!'); }); }); group('Publishes package', () { test('while showing all output from pub publish to the user', () async { - final Future publishCommand = commandRunner.run([ + processRunner.mockPublishStdout = 'Foo'; + processRunner.mockPublishStderr = 'Bar'; + processRunner.mockPublishCompleteCode = 0; + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -170,28 +190,21 @@ void main() { '--no-tag-release' ]); - processRunner.mockPublishStdout = 'Foo'; - processRunner.mockPublishStderr = 'Bar'; - processRunner.mockPublishCompleteCode = 0; - - await publishCommand; - - expect(printedMessages, contains('Foo')); - expect(printedMessages, contains('Bar')); + expect(output, contains('Foo')); + expect(output, contains('Bar')); }); test('forwards input from the user to `pub publish`', () async { - final Future publishCommand = commandRunner.run([ + mockStdin.mockUserInputs.add(utf8.encode('user input')); + processRunner.mockPublishCompleteCode = 0; + + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release' ]); - mockStdin.mockUserInputs.add(utf8.encode('user input')); - processRunner.mockPublishCompleteCode = 0; - - await publishCommand; expect(processRunner.mockPublishProcess.stdinMock.lines, contains('user input')); @@ -199,7 +212,8 @@ void main() { test('forwards --pub-publish-flags to pub publish', () async { processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -221,7 +235,8 @@ void main() { () async { processRunner.mockPublishCompleteCode = 0; _createMockCredentialFile(); - await commandRunner.run([ + + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -241,23 +256,30 @@ void main() { test('throws if pub publish fails', () async { processRunner.mockPublishCompleteCode = 128; - await expectLater( - () => commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - '--no-tag-release', - ]), - throwsA(isA())); - - expect(printedMessages, contains('Publish foo failed.')); + + Error? commandError; + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--package', + testPluginName, + '--no-push-tags', + '--no-tag-release', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Publish foo failed.'), + ])); }); test('publish, dry run', () async { - // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. - processRunner.mockPublishCompleteCode = 1; - await commandRunner.run([ + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -268,7 +290,7 @@ void main() { expect(processRunner.pushTagsArgs, isEmpty); expect( - printedMessages, + output, containsAllInOrder([ '=============== DRY RUN ===============', 'Running `pub publish ` in ${pluginDir.path}...\n', @@ -280,7 +302,8 @@ void main() { group('Tags release', () { test('with the version and name from the pubspec.yaml', () async { processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -295,16 +318,24 @@ void main() { test('only if publishing succeeded', () async { processRunner.mockPublishCompleteCode = 128; - await expectLater( - () => commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - ]), - throwsA(isA())); - - expect(printedMessages, contains('Publish foo failed.')); + + Error? commandError; + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--package', + testPluginName, + '--no-push-tags', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Publish foo failed.'), + ])); final String? tag = (await gitDir.runCommand( ['show-ref', '$testPluginName-v0.0.1'], throwOnError: false)) @@ -322,22 +353,28 @@ void main() { test('requires user confirmation', () async { processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'help'; - await expectLater( - () => commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - ]), - throwsA(isA())); - - expect(printedMessages, contains('Tag push canceled.')); + + Error? commandError; + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--package', + testPluginName, + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect(output, contains('Tag push canceled.')); }); test('to upstream by default', () async { await gitDir.runCommand(['tag', 'garbage']); processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner.run([ + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -346,7 +383,7 @@ void main() { expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'upstream'); expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(printedMessages.last, 'Done!'); + expect(output.last, 'Done!'); }); test('does not ask for user input if the --skip-confirmation flag is on', @@ -354,7 +391,9 @@ void main() { await gitDir.runCommand(['tag', 'garbage']); processRunner.mockPublishCompleteCode = 0; _createMockCredentialFile(); - await commandRunner.run([ + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--skip-confirmation', '--package', @@ -364,7 +403,7 @@ void main() { expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'upstream'); expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(printedMessages.last, 'Done!'); + expect(output.last, 'Done!'); }); test('to upstream by default, dry run', () async { @@ -372,12 +411,13 @@ void main() { // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. processRunner.mockPublishCompleteCode = 1; mockStdin.readLineOutput = 'y'; - await commandRunner.run( + + final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--package', testPluginName, '--dry-run']); expect(processRunner.pushTagsArgs, isEmpty); expect( - printedMessages, + output, containsAllInOrder([ '=============== DRY RUN ===============', 'Running `pub publish ` in ${pluginDir.path}...\n', @@ -392,7 +432,9 @@ void main() { ['remote', 'add', 'origin', 'http://localhost:8001']); processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner.run([ + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -403,12 +445,14 @@ void main() { expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'origin'); expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(printedMessages.last, 'Done!'); + expect(output.last, 'Done!'); }); test('only if tagging and pushing to remotes are both enabled', () async { processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', testPluginName, @@ -416,7 +460,7 @@ void main() { ]); expect(processRunner.pushTagsArgs.isEmpty, isTrue); - expect(printedMessages.last, 'Done!'); + expect(output.last, 'Done!'); }); }); @@ -450,7 +494,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -473,10 +516,12 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -523,7 +568,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -543,8 +587,10 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + processRunner.pushTagsArgs.clear(); // Non-federated @@ -554,11 +600,12 @@ void main() { createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Add plugins']); - // Immediately return 0 when running `pub publish`. - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + output.addAll(await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~'])); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -597,7 +644,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -617,14 +663,17 @@ void main() { // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. processRunner.mockPublishCompleteCode = 1; mockStdin.readLineOutput = 'y'; - await commandRunner.run([ + + final List output = await runCapturingPrint( + commandRunner, [ 'publish-plugin', '--all-changed', '--base-sha=HEAD~', '--dry-run' ]); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -662,7 +711,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -683,10 +731,12 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -704,7 +754,6 @@ void main() { expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.1'); processRunner.pushTagsArgs.clear(); - printedMessages.clear(); final List plugin1Pubspec = pluginDir1.childFile('pubspec.yaml').readAsLinesSync(); @@ -724,10 +773,10 @@ void main() { await gitDir .runCommand(['commit', '-m', 'Update versions to 0.0.2']); - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + final List output2 = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( - printedMessages, + output2, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -769,7 +818,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -790,10 +838,11 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -811,7 +860,6 @@ void main() { expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.1'); processRunner.pushTagsArgs.clear(); - printedMessages.clear(); final List plugin1Pubspec = pluginDir1.childFile('pubspec.yaml').readAsLinesSync(); @@ -830,10 +878,10 @@ void main() { 'Update plugin1 versions to 0.0.2, delete plugin2' ]); - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + final List output2 = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( - printedMessages, + output2, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -872,7 +920,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -895,10 +942,12 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -935,7 +984,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -956,10 +1004,17 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await expectLater( - () => commandRunner.run( - ['publish-plugin', '--all-changed', '--base-sha=HEAD~']), - throwsA(isA())); + + Error? commandError; + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--all-changed', + '--base-sha=HEAD~' + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect(processRunner.pushTagsArgs, isEmpty); }); @@ -984,10 +1039,12 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', @@ -1011,7 +1068,6 @@ void main() { }); final PublishPluginCommand command = PublishPluginCommand(packagesDir, processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), stdinput: mockStdin, httpClient: mockClient, gitDir: gitDir); @@ -1029,23 +1085,24 @@ void main() { // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, + output, containsAllInOrder([ 'Checking local repo...', 'Local repo is ready!', 'Done!' ])); expect( - printedMessages.contains( + output.contains( 'Running `pub publish ` in ${flutterPluginTools.path}...\n', ), isFalse); expect(processRunner.pushTagsArgs, isEmpty); processRunner.pushTagsArgs.clear(); - printedMessages.clear(); }); }); } From 5b5f8016d31a172470ae5da49f2c0b57e2fe2481 Mon Sep 17 00:00:00 2001 From: BeMacized Date: Wed, 25 Aug 2021 16:41:09 +0200 Subject: [PATCH 217/364] [camera] Expand CameraImage DTO with properties for lens aperture, exposure time and ISO. (#4256) --- packages/camera/camera/CHANGELOG.md | 4 ++ .../io/flutter/plugins/camera/Camera.java | 11 ++- .../plugins/camera/CameraCaptureCallback.java | 21 +++++- .../camera/types/CameraCaptureProperties.java | 67 +++++++++++++++++ .../CameraCaptureCallbackStatesTest.java | 6 +- .../camera/CameraCaptureCallbackTest.java | 72 +++++++++++++++++++ .../camera/camera/ios/Classes/CameraPlugin.m | 5 ++ .../camera/camera/lib/src/camera_image.dart | 14 ++++ packages/camera/camera/pubspec.yaml | 2 +- .../camera/camera/test/camera_image_test.dart | 15 ++++ 10 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraCaptureProperties.java create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackTest.java diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 68188d6510ff..73cce2c539c1 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.1 + +* Added `lensAperture`, `sensorExposureTime` and `sensorSensitivity` properties to the `CameraImage` dto. + ## 0.9.0 * Complete rewrite of Android plugin to fix many capture, focus, flash, orientation and exposure issues. diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index 4724d22a1bcd..43479aca616c 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -61,6 +61,7 @@ import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; import io.flutter.plugins.camera.media.MediaRecorderBuilder; +import io.flutter.plugins.camera.types.CameraCaptureProperties; import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; import io.flutter.view.TextureRegistry.SurfaceTextureEntry; import java.io.File; @@ -130,6 +131,8 @@ class Camera /** Holds the current capture timeouts */ private CaptureTimeoutsWrapper captureTimeouts; + /** Holds the last known capture properties */ + private CameraCaptureProperties captureProps; private MethodChannel.Result flutterResult; @@ -158,7 +161,8 @@ public Camera( // Create capture callback. captureTimeouts = new CaptureTimeoutsWrapper(3000, 3000); - cameraCaptureCallback = CameraCaptureCallback.create(this, captureTimeouts); + captureProps = new CameraCaptureProperties(); + cameraCaptureCallback = CameraCaptureCallback.create(this, captureTimeouts, captureProps); startBackgroundThread(); } @@ -1042,6 +1046,11 @@ private void setImageStreamImageAvailableListener(final EventChannel.EventSink i imageBuffer.put("height", img.getHeight()); imageBuffer.put("format", img.getFormat()); imageBuffer.put("planes", planes); + imageBuffer.put("lensAperture", this.captureProps.getLastLensAperture()); + imageBuffer.put("sensorExposureTime", this.captureProps.getLastSensorExposureTime()); + Integer sensorSensitivity = this.captureProps.getLastSensorSensitivity(); + imageBuffer.put( + "sensorSensitivity", sensorSensitivity == null ? null : (double) sensorSensitivity); final Handler handler = new Handler(Looper.getMainLooper()); handler.post(() -> imageStreamSink.success(imageBuffer)); diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java index 21dcb602655d..805f18298958 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java @@ -11,6 +11,7 @@ import android.hardware.camera2.TotalCaptureResult; import android.util.Log; import androidx.annotation.NonNull; +import io.flutter.plugins.camera.types.CameraCaptureProperties; import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; /** @@ -22,13 +23,16 @@ class CameraCaptureCallback extends CaptureCallback { private final CameraCaptureStateListener cameraStateListener; private CameraState cameraState; private final CaptureTimeoutsWrapper captureTimeouts; + private final CameraCaptureProperties captureProps; private CameraCaptureCallback( @NonNull CameraCaptureStateListener cameraStateListener, - @NonNull CaptureTimeoutsWrapper captureTimeouts) { + @NonNull CaptureTimeoutsWrapper captureTimeouts, + @NonNull CameraCaptureProperties captureProps) { cameraState = CameraState.STATE_PREVIEW; this.cameraStateListener = cameraStateListener; this.captureTimeouts = captureTimeouts; + this.captureProps = captureProps; } /** @@ -41,8 +45,9 @@ private CameraCaptureCallback( */ public static CameraCaptureCallback create( @NonNull CameraCaptureStateListener cameraStateListener, - @NonNull CaptureTimeoutsWrapper captureTimeouts) { - return new CameraCaptureCallback(cameraStateListener, captureTimeouts); + @NonNull CaptureTimeoutsWrapper captureTimeouts, + @NonNull CameraCaptureProperties captureProps) { + return new CameraCaptureCallback(cameraStateListener, captureTimeouts, captureProps); } /** @@ -67,6 +72,16 @@ private void process(CaptureResult result) { Integer aeState = result.get(CaptureResult.CONTROL_AE_STATE); Integer afState = result.get(CaptureResult.CONTROL_AF_STATE); + // Update capture properties + if (result instanceof TotalCaptureResult) { + Float lensAperture = result.get(CaptureResult.LENS_APERTURE); + Long sensorExposureTime = result.get(CaptureResult.SENSOR_EXPOSURE_TIME); + Integer sensorSensitivity = result.get(CaptureResult.SENSOR_SENSITIVITY); + this.captureProps.setLastLensAperture(lensAperture); + this.captureProps.setLastSensorExposureTime(sensorExposureTime); + this.captureProps.setLastSensorSensitivity(sensorSensitivity); + } + if (cameraState != CameraState.STATE_PREVIEW) { Log.d( TAG, diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraCaptureProperties.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraCaptureProperties.java new file mode 100644 index 000000000000..68177f4ecfd6 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CameraCaptureProperties.java @@ -0,0 +1,67 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera.types; + +public class CameraCaptureProperties { + + private Float lastLensAperture; + private Long lastSensorExposureTime; + private Integer lastSensorSensitivity; + + /** + * Gets the last known lens aperture. (As f-stop value) + * + * @return the last known lens aperture. (As f-stop value) + */ + public Float getLastLensAperture() { + return lastLensAperture; + } + + /** + * Sets the last known lens aperture. (As f-stop value) + * + * @param lastLensAperture - The last known lens aperture to set. (As f-stop value) + */ + public void setLastLensAperture(Float lastLensAperture) { + this.lastLensAperture = lastLensAperture; + } + + /** + * Gets the last known sensor exposure time in nanoseconds. + * + * @return the last known sensor exposure time in nanoseconds. + */ + public Long getLastSensorExposureTime() { + return lastSensorExposureTime; + } + + /** + * Sets the last known sensor exposure time in nanoseconds. + * + * @param lastSensorExposureTime - The last known sensor exposure time to set, in nanoseconds. + */ + public void setLastSensorExposureTime(Long lastSensorExposureTime) { + this.lastSensorExposureTime = lastSensorExposureTime; + } + + /** + * Gets the last known sensor sensitivity in ISO arithmetic units. + * + * @return the last known sensor sensitivity in ISO arithmetic units. + */ + public Integer getLastSensorSensitivity() { + return lastSensorSensitivity; + } + + /** + * Sets the last known sensor sensitivity in ISO arithmetic units. + * + * @param lastSensorSensitivity - The last known sensor sensitivity to set, in ISO arithmetic + * units. + */ + public void setLastSensorSensitivity(Integer lastSensorSensitivity) { + this.lastSensorSensitivity = lastSensorSensitivity; + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java index 4964aef8b8c9..934aff857ec7 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java @@ -17,6 +17,7 @@ import android.hardware.camera2.CaptureResult.Key; import android.hardware.camera2.TotalCaptureResult; import io.flutter.plugins.camera.CameraCaptureCallback.CameraCaptureStateListener; +import io.flutter.plugins.camera.types.CameraCaptureProperties; import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; import io.flutter.plugins.camera.types.Timeout; import io.flutter.plugins.camera.utils.TestUtils; @@ -40,6 +41,7 @@ public class CameraCaptureCallbackStatesTest extends TestCase { private CaptureRequest mockCaptureRequest; private CaptureResult mockPartialCaptureResult; private CaptureTimeoutsWrapper mockCaptureTimeouts; + private CameraCaptureProperties mockCaptureProps; private TotalCaptureResult mockTotalCaptureResult; private MockedStatic mockedStaticTimeout; private Timeout mockTimeout; @@ -83,6 +85,7 @@ protected void setUp() throws Exception { mockTotalCaptureResult = mock(TotalCaptureResult.class); mockTimeout = mock(Timeout.class); mockCaptureTimeouts = mock(CaptureTimeoutsWrapper.class); + mockCaptureProps = mock(CameraCaptureProperties.class); when(mockCaptureTimeouts.getPreCaptureFocusing()).thenReturn(mockTimeout); when(mockCaptureTimeouts.getPreCaptureMetering()).thenReturn(mockTimeout); @@ -95,7 +98,8 @@ protected void setUp() throws Exception { mockedStaticTimeout.when(() -> Timeout.create(1000)).thenReturn(mockTimeout); cameraCaptureCallback = - CameraCaptureCallback.create(mockCaptureStateListener, mockCaptureTimeouts); + CameraCaptureCallback.create( + mockCaptureStateListener, mockCaptureTimeouts, mockCaptureProps); } @Override diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackTest.java new file mode 100644 index 000000000000..75a5b25995e2 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackTest.java @@ -0,0 +1,72 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.CaptureResult; +import android.hardware.camera2.TotalCaptureResult; +import io.flutter.plugins.camera.types.CameraCaptureProperties; +import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class CameraCaptureCallbackTest { + + private CameraCaptureCallback cameraCaptureCallback; + private CameraCaptureProperties mockCaptureProps; + + @Before + public void setUp() { + CameraCaptureCallback.CameraCaptureStateListener mockCaptureStateListener = + mock(CameraCaptureCallback.CameraCaptureStateListener.class); + CaptureTimeoutsWrapper mockCaptureTimeouts = mock(CaptureTimeoutsWrapper.class); + mockCaptureProps = mock(CameraCaptureProperties.class); + cameraCaptureCallback = + CameraCaptureCallback.create( + mockCaptureStateListener, mockCaptureTimeouts, mockCaptureProps); + } + + @Test + public void onCaptureProgressed_doesNotUpdateCameraCaptureProperties() { + CameraCaptureSession mockSession = mock(CameraCaptureSession.class); + CaptureRequest mockRequest = mock(CaptureRequest.class); + CaptureResult mockResult = mock(CaptureResult.class); + + cameraCaptureCallback.onCaptureProgressed(mockSession, mockRequest, mockResult); + + verify(mockCaptureProps, never()).setLastLensAperture(anyFloat()); + verify(mockCaptureProps, never()).setLastSensorExposureTime(anyLong()); + verify(mockCaptureProps, never()).setLastSensorSensitivity(anyInt()); + } + + @Test + public void onCaptureCompleted_updatesCameraCaptureProperties() { + CameraCaptureSession mockSession = mock(CameraCaptureSession.class); + CaptureRequest mockRequest = mock(CaptureRequest.class); + TotalCaptureResult mockResult = mock(TotalCaptureResult.class); + when(mockResult.get(CaptureResult.LENS_APERTURE)).thenReturn(1.0f); + when(mockResult.get(CaptureResult.SENSOR_EXPOSURE_TIME)).thenReturn(2L); + when(mockResult.get(CaptureResult.SENSOR_SENSITIVITY)).thenReturn(3); + + cameraCaptureCallback.onCaptureCompleted(mockSession, mockRequest, mockResult); + + verify(mockCaptureProps, times(1)).setLastLensAperture(1.0f); + verify(mockCaptureProps, times(1)).setLastSensorExposureTime(2L); + verify(mockCaptureProps, times(1)).setLastSensorSensitivity(3); + } +} diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index d88eb45945fe..ea03ce57649c 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -661,6 +661,11 @@ - (void)captureOutput:(AVCaptureOutput *)output imageBuffer[@"height"] = [NSNumber numberWithUnsignedLong:imageHeight]; imageBuffer[@"format"] = @(videoFormat); imageBuffer[@"planes"] = planes; + imageBuffer[@"lensAperture"] = [NSNumber numberWithFloat:[_captureDevice lensAperture]]; + Float64 exposureDuration = CMTimeGetSeconds([_captureDevice exposureDuration]); + Float64 nsExposureDuration = 1000000000 * exposureDuration; + imageBuffer[@"sensorExposureTime"] = [NSNumber numberWithInt:nsExposureDuration]; + imageBuffer[@"sensorSensitivity"] = [NSNumber numberWithFloat:[_captureDevice ISO]]; _imageStreamHandler.eventSink(imageBuffer); diff --git a/packages/camera/camera/lib/src/camera_image.dart b/packages/camera/camera/lib/src/camera_image.dart index 411c7e86db41..43fa763bed48 100644 --- a/packages/camera/camera/lib/src/camera_image.dart +++ b/packages/camera/camera/lib/src/camera_image.dart @@ -100,6 +100,9 @@ class CameraImage { : format = ImageFormat._fromPlatformData(data['format']), height = data['height'], width = data['width'], + lensAperture = data['lensAperture'], + sensorExposureTime = data['sensorExposureTime'], + sensorSensitivity = data['sensorSensitivity'], planes = List.unmodifiable(data['planes'] .map((dynamic planeData) => Plane._fromPlatformData(planeData))); @@ -125,4 +128,15 @@ class CameraImage { /// /// The number of planes is determined by the format of the image. final List planes; + + /// The aperture settings for this image. + /// + /// Represented as an f-stop value. + final double? lensAperture; + + /// The sensor exposure time for this image in nanoseconds. + final int? sensorExposureTime; + + /// The sensor sensitivity in standard ISO arithmetic units. + final double? sensorSensitivity; } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index a7c6a61a4ef2..08d1e3eead4f 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.0 +version: 0.9.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/camera/camera/test/camera_image_test.dart b/packages/camera/camera/test/camera_image_test.dart index 2d827d983f3a..85d613f41485 100644 --- a/packages/camera/camera/test/camera_image_test.dart +++ b/packages/camera/camera/test/camera_image_test.dart @@ -18,6 +18,9 @@ void main() { 'format': 35, 'height': 1, 'width': 4, + 'lensAperture': 1.8, + 'sensorExposureTime': 9991324, + 'sensorSensitivity': 92.0, 'planes': [ { 'bytes': Uint8List.fromList([1, 2, 3, 4]), @@ -41,6 +44,9 @@ void main() { 'format': 875704438, 'height': 1, 'width': 4, + 'lensAperture': 1.8, + 'sensorExposureTime': 9991324, + 'sensorSensitivity': 92.0, 'planes': [ { 'bytes': Uint8List.fromList([1, 2, 3, 4]), @@ -61,6 +67,9 @@ void main() { 'format': 35, 'height': 1, 'width': 4, + 'lensAperture': 1.8, + 'sensorExposureTime': 9991324, + 'sensorSensitivity': 92.0, 'planes': [ { 'bytes': Uint8List.fromList([1, 2, 3, 4]), @@ -81,6 +90,9 @@ void main() { 'format': 1111970369, 'height': 1, 'width': 4, + 'lensAperture': 1.8, + 'sensorExposureTime': 9991324, + 'sensorSensitivity': 92.0, 'planes': [ { 'bytes': Uint8List.fromList([1, 2, 3, 4]), @@ -98,6 +110,9 @@ void main() { 'format': null, 'height': 1, 'width': 4, + 'lensAperture': 1.8, + 'sensorExposureTime': 9991324, + 'sensorSensitivity': 92.0, 'planes': [ { 'bytes': Uint8List.fromList([1, 2, 3, 4]), From 88f84104f8df94938fe67716a38d5adc6e1fd81a Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 25 Aug 2021 16:39:58 -0400 Subject: [PATCH 218/364] [flutter_plugin_tools] Convert publish tests to mock git (#4263) Replaces the use of an actual git repository on the filesystem with mock git output and an in-memory filesystem. This: - makes the tests more hermetic. - simplifies the setup of some tests considerably, avoiding the need to run the command once to set up the expected state before running a second time for the intended test. - eliminates some of the special handling in the test's custom process runner (making it easier to eliminate in a PR that will follow after). Also adds some output checking in a couple of tests that didn't have enough to ensure that they were necessarily testing the right thing (e.g., testing that a specific thing didn't happen, but not checking that the publish step that could have caused that thing to happen even ran at all). --- .../tool/lib/src/publish_plugin_command.dart | 24 +- .../test/publish_plugin_command_test.dart | 563 +++++++++--------- 2 files changed, 285 insertions(+), 302 deletions(-) diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 5a75ce6af89f..be9e6d300125 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -355,11 +355,9 @@ Safe to ignore if the package is deleted in this commit. final String tag = _getTag(packageDir); print('Tagging release $tag...'); if (!getBoolArg(_dryRunFlag)) { - final io.ProcessResult result = await processRunner.run( - 'git', + final io.ProcessResult result = await (await gitDir).runCommand( ['tag', tag], - workingDir: packageDir, - logOnError: true, + throwOnError: false, ); if (result.exitCode != 0) { return false; @@ -400,11 +398,9 @@ Safe to ignore if the package is deleted in this commit. } Future _checkGitStatus(Directory packageDir) async { - final io.ProcessResult statusResult = await processRunner.run( - 'git', + final io.ProcessResult statusResult = await (await gitDir).runCommand( ['status', '--porcelain', '--ignored', packageDir.absolute.path], - workingDir: packageDir, - logOnError: true, + throwOnError: false, ); if (statusResult.exitCode != 0) { return false; @@ -421,11 +417,9 @@ Safe to ignore if the package is deleted in this commit. } Future _verifyRemote(String remote) async { - final io.ProcessResult getRemoteUrlResult = await processRunner.run( - 'git', + final io.ProcessResult getRemoteUrlResult = await (await gitDir).runCommand( ['remote', 'get-url', remote], - workingDir: packagesDir, - logOnError: true, + throwOnError: false, ); if (getRemoteUrlResult.exitCode != 0) { return null; @@ -494,11 +488,9 @@ Safe to ignore if the package is deleted in this commit. } } if (!getBoolArg(_dryRunFlag)) { - final io.ProcessResult result = await processRunner.run( - 'git', + final io.ProcessResult result = await (await gitDir).runCommand( ['push', remote.name, tag], - workingDir: packagesDir, - logOnError: true, + throwOnError: false, ); if (result.exitCode != 0) { return false; diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 576d3a4c88c8..40018b6edb61 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -8,34 +8,31 @@ import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; -import 'package:file/local.dart'; +import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; -import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; import 'package:test/test.dart'; +import 'common/plugin_command_test.mocks.dart'; import 'mocks.dart'; import 'util.dart'; void main() { const String testPluginName = 'foo'; - late Directory testRoot; late Directory packagesDir; late Directory pluginDir; - late GitDir gitDir; + late MockGitDir gitDir; late TestProcessRunner processRunner; + late RecordingProcessRunner gitProcessRunner; late CommandRunner commandRunner; late MockStdin mockStdin; - // This test uses a local file system instead of an in memory one throughout - // so that git actually works. In setup we initialize a mono repo of plugins - // with one package and commit everything to Git. - const FileSystem fileSystem = LocalFileSystem(); + late FileSystem fileSystem; void _createMockCredentialFile() { final String credentialPath = PublishPluginCommand.getCredentialPath(); @@ -45,20 +42,26 @@ void main() { } setUp(() async { - testRoot = fileSystem.systemTempDirectory - .createTempSync('publish_plugin_command_test-'); - // The temp directory can have symbolic links, which won't match git output; - // use a fully resolved version to avoid potential path comparison issues. - testRoot = fileSystem.directory(testRoot.resolveSymbolicLinksSync()); - packagesDir = createPackagesDirectory(parentDir: testRoot); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + // TODO(stuartmorgan): Move this from setup to individual tests. pluginDir = createFakePlugin(testPluginName, packagesDir, examples: []); assert(pluginDir != null && pluginDir.existsSync()); - io.Process.runSync('git', ['init'], - workingDirectory: testRoot.path); - gitDir = await GitDir.fromExisting(testRoot.path); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Initial commit']); + + gitProcessRunner = RecordingProcessRunner(); + gitDir = MockGitDir(); + when(gitDir.path).thenReturn(packagesDir.parent.path); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + final List arguments = + invocation.positionalArguments[0]! as List; + // Attach the first argument to the command to make targeting the mock + // results easier. + final String gitCommand = arguments.removeAt(0); + return gitProcessRunner.run('git-$gitCommand', arguments); + }); + processRunner = TestProcessRunner(); mockStdin = MockStdin(); commandRunner = CommandRunner('tester', '') @@ -66,10 +69,6 @@ void main() { processRunner: processRunner, stdinput: mockStdin, gitDir: gitDir)); }); - tearDown(() { - testRoot.deleteSync(recursive: true); - }); - group('Initial validation', () { test('requires a package flag', () async { Error? commandError; @@ -79,7 +78,11 @@ void main() { }); expect(commandError, isA()); - expect(output.last, contains('Must specify a package to publish.')); + expect( + output, + containsAllInOrder([ + contains('Must specify a package to publish.'), + ])); }); test('requires an existing flag', () async { @@ -91,11 +94,14 @@ void main() { }); expect(commandError, isA()); - expect(output.last, contains('iamerror does not exist')); + expect(output, + containsAllInOrder([contains('iamerror does not exist')])); }); test('refuses to proceed with dirty files', () async { - pluginDir.childFile('tmp').createSync(); + gitProcessRunner.mockProcessesForExecutable['git-status'] = [ + MockProcess(stdout: '?? ${pluginDir.childFile('tmp').path}\n') + ]; Error? commandError; final List output = await runCapturingPrint( @@ -114,7 +120,7 @@ void main() { containsAllInOrder([ contains('There are files in the package directory that haven\'t ' 'been saved in git. Refusing to publish these files:\n\n' - '?? packages/foo/tmp\n\n' + '?? /packages/foo/tmp\n\n' 'If the directory should be clean, you can run `git clean -xdf && ' 'git reset --hard HEAD` to wipe all local changes.'), contains('Failed, see above for details.'), @@ -122,20 +128,32 @@ void main() { }); test('fails immediately if the remote doesn\'t exist', () async { + gitProcessRunner.mockProcessesForExecutable['git-remote'] = [ + MockProcess(exitCode: 1), + ]; + Error? commandError; - await runCapturingPrint(commandRunner, [ - 'publish-plugin', - '--package', - testPluginName - ], errorHandler: (Error e) { + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--package', testPluginName], + errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); - expect(processRunner.results.last.stderr, contains('No such remote')); + expect( + output, + containsAllInOrder([ + contains( + 'Unable to find URL for remote upstream; cannot push tags'), + ])); }); test("doesn't validate the remote if it's not pushing tags", () async { + // Checking the remote should fail. + gitProcessRunner.mockProcessesForExecutable['git-remote'] = [ + MockProcess(exitCode: 1), + ]; + // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; @@ -148,17 +166,18 @@ void main() { '--no-tag-release' ]); - expect(output.last, 'Done!'); + expect( + output, + containsAllInOrder([ + contains('Running `pub publish ` in /packages/$testPluginName...'), + contains('Package published!'), + contains('Released [$testPluginName] successfully.'), + ])); }); test('can publish non-flutter package', () async { const String packageName = 'a_package'; createFakePackage(packageName, packagesDir); - io.Process.runSync('git', ['init'], - workingDirectory: testRoot.path); - gitDir = await GitDir.fromExisting(testRoot.path); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Initial commit']); // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; @@ -171,7 +190,15 @@ void main() { '--no-tag-release' ]); - expect(output.last, 'Done!'); + expect( + output, + containsAllInOrder( + [ + contains('Running `pub publish ` in /packages/a_package...'), + contains('Package published!'), + ], + ), + ); }); }); @@ -190,8 +217,12 @@ void main() { '--no-tag-release' ]); - expect(output, contains('Foo')); - expect(output, contains('Bar')); + expect( + output, + containsAllInOrder([ + contains('Foo'), + contains('Bar'), + ])); }); test('forwards input from the user to `pub publish`', () async { @@ -288,7 +319,10 @@ void main() { '--no-tag-release', ]); - expect(processRunner.pushTagsArgs, isEmpty); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); expect( output, containsAllInOrder([ @@ -310,10 +344,10 @@ void main() { '--no-push-tags', ]); - final String? tag = (await gitDir - .runCommand(['show-ref', '$testPluginName-v0.0.1'])) - .stdout as String?; - expect(tag, isNotEmpty); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-tag', ['$testPluginName-v0.0.1'], null))); }); test('only if publishing succeeded', () async { @@ -336,20 +370,14 @@ void main() { containsAllInOrder([ contains('Publish foo failed.'), ])); - final String? tag = (await gitDir.runCommand( - ['show-ref', '$testPluginName-v0.0.1'], - throwOnError: false)) - .stdout as String?; - expect(tag, isEmpty); + expect( + gitProcessRunner.recordedCalls, + isNot(contains( + const ProcessCall('git-tag', ['foo-v0.0.1'], null)))); }); }); group('Pushes tags', () { - setUp(() async { - await gitDir.runCommand( - ['remote', 'add', 'upstream', 'http://localhost:8000']); - }); - test('requires user confirmation', () async { processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'help'; @@ -369,7 +397,6 @@ void main() { }); test('to upstream by default', () async { - await gitDir.runCommand(['tag', 'garbage']); processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -380,15 +407,19 @@ void main() { testPluginName, ]); - expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(output.last, 'Done!'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall('git-push', + ['upstream', '$testPluginName-v0.0.1'], null))); + expect( + output, + containsAllInOrder([ + contains('Released [$testPluginName] successfully.'), + ])); }); test('does not ask for user input if the --skip-confirmation flag is on', () async { - await gitDir.runCommand(['tag', 'garbage']); processRunner.mockPublishCompleteCode = 0; _createMockCredentialFile(); @@ -400,14 +431,18 @@ void main() { testPluginName, ]); - expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(output.last, 'Done!'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall('git-push', + ['upstream', '$testPluginName-v0.0.1'], null))); + expect( + output, + containsAllInOrder([ + contains('Released [$testPluginName] successfully.'), + ])); }); test('to upstream by default, dry run', () async { - await gitDir.runCommand(['tag', 'garbage']); // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. processRunner.mockPublishCompleteCode = 1; mockStdin.readLineOutput = 'y'; @@ -415,7 +450,10 @@ void main() { final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--package', testPluginName, '--dry-run']); - expect(processRunner.pushTagsArgs, isEmpty); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); expect( output, containsAllInOrder([ @@ -428,8 +466,6 @@ void main() { }); test('to different remotes based on a flag', () async { - await gitDir.runCommand( - ['remote', 'add', 'origin', 'http://localhost:8001']); processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -442,10 +478,15 @@ void main() { 'origin', ]); - expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); - expect(processRunner.pushTagsArgs[1], 'origin'); - expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(output.last, 'Done!'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['origin', '$testPluginName-v0.0.1'], null))); + expect( + output, + containsAllInOrder([ + contains('Released [$testPluginName] successfully.'), + ])); }); test('only if tagging and pushing to remotes are both enabled', () async { @@ -459,20 +500,21 @@ void main() { '--no-tag-release', ]); - expect(processRunner.pushTagsArgs.isEmpty, isTrue); - expect(output.last, 'Done!'); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); + expect( + output, + containsAllInOrder([ + contains('Running `pub publish ` in /packages/$testPluginName...'), + contains('Package published!'), + contains('Released [$testPluginName] successfully.'), + ])); }); }); group('Auto release (all-changed flag)', () { - setUp(() async { - io.Process.runSync('git', ['init'], - workingDirectory: testRoot.path); - gitDir = await GitDir.fromExisting(testRoot.path); - await gitDir.runCommand( - ['remote', 'add', 'upstream', 'http://localhost:8000']); - }); - test('can release newly created plugins', () async { const Map httpResponsePlugin1 = { 'name': 'plugin1', @@ -511,8 +553,11 @@ void main() { 'plugin2', packagesDir.childDirectory('plugin2'), ); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; @@ -530,13 +575,14 @@ void main() { 'Packages released: plugin1, plugin2', 'Done!' ])); - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.1'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.1'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.1'], null))); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin2-v0.0.1'], null))); }); test('can release newly created plugins, while there are existing plugins', @@ -578,11 +624,24 @@ void main() { ); commandRunner.addCommand(command); - // Prepare an exiting plugin and tag it + // The existing plugin. createFakePlugin('plugin0', packagesDir); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - await gitDir.runCommand(['tag', 'plugin0-v0.0.1']); + // Non-federated + final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); + // federated + final Directory pluginDir2 = + createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); + + // Git results for plugin0 having been released already, and plugin1 and + // plugin2 being new. + gitProcessRunner.mockProcessesForExecutable['git-tag'] = [ + MockProcess(stdout: 'plugin0-v0.0.1\n') + ]; + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; @@ -591,19 +650,6 @@ void main() { final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - processRunner.pushTagsArgs.clear(); - - // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); - // federated - final Directory pluginDir2 = - createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - - output.addAll(await runCapturingPrint(commandRunner, - ['publish-plugin', '--all-changed', '--base-sha=HEAD~'])); - expect( output, containsAllInOrder([ @@ -614,13 +660,14 @@ void main() { 'Packages released: plugin1, plugin2', 'Done!' ])); - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.1'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.1'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.1'], null))); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin2-v0.0.1'], null))); }); test('can release newly created plugins, dry run', () async { @@ -658,10 +705,12 @@ void main() { // federated final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. - processRunner.mockPublishCompleteCode = 1; + + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint( @@ -687,18 +736,21 @@ void main() { 'Packages released: plugin1, plugin2', 'Done!' ])); - expect(processRunner.pushTagsArgs, isEmpty); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); test('version change triggers releases.', () async { const Map httpResponsePlugin1 = { 'name': 'plugin1', - 'versions': [], + 'versions': ['0.0.1'], }; const Map httpResponsePlugin2 = { 'name': 'plugin2', - 'versions': [], + 'versions': ['0.0.1'], }; final MockClient mockClient = MockClient((http.Request request) async { @@ -722,57 +774,23 @@ void main() { commandRunner.addCommand(command); // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated - final Directory pluginDir2 = - createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); + final Directory pluginDir2 = createFakePlugin( + 'plugin2', packagesDir.childDirectory('plugin2'), + version: '0.0.2'); + + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; + // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - - expect( - output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', - 'Done!' - ])); - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.1'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.1'); - - processRunner.pushTagsArgs.clear(); - - final List plugin1Pubspec = - pluginDir1.childFile('pubspec.yaml').readAsLinesSync(); - plugin1Pubspec[plugin1Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.2'; - pluginDir1 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin1Pubspec.join('\n')); - final List plugin2Pubspec = - pluginDir2.childFile('pubspec.yaml').readAsLinesSync(); - plugin2Pubspec[plugin2Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.2'; - pluginDir2 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin2Pubspec.join('\n')); - await gitDir.runCommand(['add', '-A']); - await gitDir - .runCommand(['commit', '-m', 'Update versions to 0.0.2']); - final List output2 = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( @@ -785,14 +803,14 @@ void main() { 'Packages released: plugin1, plugin2', 'Done!' ])); - - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.2'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.2'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.2'], null))); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin2-v0.0.2'], null))); }); test( @@ -800,12 +818,12 @@ void main() { () async { const Map httpResponsePlugin1 = { 'name': 'plugin1', - 'versions': [], + 'versions': ['0.0.1'], }; const Map httpResponsePlugin2 = { 'name': 'plugin2', - 'versions': [], + 'versions': ['0.0.1'], }; final MockClient mockClient = MockClient((http.Request request) async { @@ -829,55 +847,23 @@ void main() { commandRunner.addCommand(command); // Non-federated - final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); + pluginDir2.deleteSync(recursive: true); + + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; + // Immediately return 0 when running `pub publish`. processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; - final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - expect( - output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', - 'Done!' - ])); - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.1'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.1'); - - processRunner.pushTagsArgs.clear(); - - final List plugin1Pubspec = - pluginDir1.childFile('pubspec.yaml').readAsLinesSync(); - plugin1Pubspec[plugin1Pubspec.indexWhere( - (String element) => element.contains('version:'))] = 'version: 0.0.2'; - pluginDir1 - .childFile('pubspec.yaml') - .writeAsStringSync(plugin1Pubspec.join('\n')); - - pluginDir2.deleteSync(recursive: true); - - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand([ - 'commit', - '-m', - 'Update plugin1 versions to 0.0.2, delete plugin2' - ]); - final List output2 = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( @@ -890,15 +876,13 @@ void main() { 'Packages released: plugin1', 'Done!' ])); - - expect(processRunner.pushTagsArgs, isNotEmpty); - expect(processRunner.pushTagsArgs.length, 3); - expect(processRunner.pushTagsArgs[0], 'push'); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], 'plugin1-v0.0.2'); + expect( + gitProcessRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.2'], null))); }); - test('Exiting versions do not trigger release, also prints out message.', + test('Existing versions do not trigger release, also prints out message.', () async { const Map httpResponsePlugin1 = { 'name': 'plugin1', @@ -931,17 +915,23 @@ void main() { commandRunner.addCommand(command); // Non-federated - createFakePlugin('plugin1', packagesDir, version: '0.0.2'); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated - createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'), + final Directory pluginDir2 = createFakePlugin( + 'plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - await gitDir.runCommand(['tag', 'plugin1-v0.0.2']); - await gitDir.runCommand(['tag', 'plugin2-v0.0.2']); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - mockStdin.readLineOutput = 'y'; + + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; + gitProcessRunner.mockProcessesForExecutable['git-tag'] = [ + MockProcess( + stdout: 'plugin1-v0.0.2\n' + 'plugin2-v0.0.2\n') + ]; final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); @@ -958,11 +948,14 @@ void main() { 'Done!' ])); - expect(processRunner.pushTagsArgs, isEmpty); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); test( - 'Exiting versions do not trigger release, but fail if the tags do not exist.', + 'Existing versions do not trigger release, but fail if the tags do not exist.', () async { const Map httpResponsePlugin1 = { 'name': 'plugin1', @@ -995,27 +988,41 @@ void main() { commandRunner.addCommand(command); // Non-federated - createFakePlugin('plugin1', packagesDir, version: '0.0.2'); + final Directory pluginDir1 = + createFakePlugin('plugin1', packagesDir, version: '0.0.2'); // federated - createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'), + final Directory pluginDir2 = createFakePlugin( + 'plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - mockStdin.readLineOutput = 'y'; + + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; Error? commandError; - await runCapturingPrint(commandRunner, [ - 'publish-plugin', - '--all-changed', - '--base-sha=HEAD~' - ], errorHandler: (Error e) { + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~'], + errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); - expect(processRunner.pushTagsArgs, isEmpty); + expect( + output, + containsAllInOrder([ + contains('The version 0.0.2 of plugin1 has already been published'), + contains( + 'However, the git release tag for this version (plugin1-v0.0.2) is not found.'), + contains('The version 0.0.2 of plugin2 has already been published'), + contains( + 'However, the git release tag for this version (plugin2-v0.0.2) is not found.'), + ])); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); test('No version change does not release any plugins', () async { @@ -1025,20 +1032,11 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - io.Process.runSync('git', ['init'], - workingDirectory: testRoot.path); - gitDir = await GitDir.fromExisting(testRoot.path); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - - pluginDir1.childFile('plugin1.dart').createSync(); - pluginDir2.childFile('plugin2.dart').createSync(); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add dart files']); - - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - mockStdin.readLineOutput = 'y'; + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('plugin1.dart').path}\n' + '${pluginDir2.childFile('plugin2.dart').path}\n') + ]; final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); @@ -1051,7 +1049,10 @@ void main() { 'No version updates in this commit.', 'Done!' ])); - expect(processRunner.pushTagsArgs, isEmpty); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); test('Do not release flutter_plugin_tools', () async { @@ -1080,11 +1081,9 @@ void main() { final Directory flutterPluginTools = createFakePlugin('flutter_plugin_tools', packagesDir); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - mockStdin.readLineOutput = 'y'; + gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: flutterPluginTools.childFile('pubspec.yaml').path) + ]; final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); @@ -1101,19 +1100,18 @@ void main() { 'Running `pub publish ` in ${flutterPluginTools.path}...\n', ), isFalse); - expect(processRunner.pushTagsArgs, isEmpty); - processRunner.pushTagsArgs.clear(); + expect( + gitProcessRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); }); } class TestProcessRunner extends ProcessRunner { - final List results = []; // Most recent returned publish process. late MockProcess mockPublishProcess; final List mockPublishArgs = []; - final MockProcessResult mockPushTagsResult = MockProcessResult(); - final List pushTagsArgs = []; String? mockPublishStdout; String? mockPublishStderr; @@ -1129,15 +1127,8 @@ class TestProcessRunner extends ProcessRunner { Encoding stdoutEncoding = io.systemEncoding, Encoding stderrEncoding = io.systemEncoding, }) async { - // Don't ever really push tags. - if (executable == 'git' && args.isNotEmpty && args[0] == 'push') { - pushTagsArgs.addAll(args); - return mockPushTagsResult; - } - final io.ProcessResult result = io.Process.runSync(executable, args, workingDirectory: workingDir?.path); - results.add(result); if (result.exitCode != 0) { throw ToolExit(result.exitCode); } From 56f092a8105d2a21d26844edb1bf8458f79f195e Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 26 Aug 2021 02:36:07 +0200 Subject: [PATCH 219/364] [camera_web] Add `onCameraClosing` implementation (#4259) --- .../example/integration_test/camera_test.dart | 74 ++++++- .../integration_test/camera_web_test.dart | 195 ++++++++++++------ .../camera/camera_web/lib/src/camera.dart | 43 +++- .../camera/camera_web/lib/src/camera_web.dart | 18 +- 4 files changed, 267 insertions(+), 63 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 1d1659352f26..f331cc1485ab 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -5,6 +5,7 @@ import 'dart:html'; import 'dart:ui'; +import 'package:async/async.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_service.dart'; @@ -843,10 +844,81 @@ void main() { await camera.initialize(); - camera.dispose(); + await camera.dispose(); expect(camera.videoElement.srcObject, isNull); }); }); + + group('events', () { + group('onEnded', () { + testWidgets( + 'emits the default video track ' + 'when it emits an ended event', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final streamQueue = StreamQueue(camera.onEnded); + + await camera.initialize(); + + final videoTracks = camera.stream!.getVideoTracks(); + final defaultVideoTrack = videoTracks.first; + + defaultVideoTrack.dispatchEvent(Event('ended')); + + expect( + await streamQueue.next, + equals(defaultVideoTrack), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits the default video track ' + 'when the camera is stopped', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final streamQueue = StreamQueue(camera.onEnded); + + await camera.initialize(); + + final videoTracks = camera.stream!.getVideoTracks(); + final defaultVideoTrack = videoTracks.first; + + camera.stop(); + + expect( + await streamQueue.next, + equals(defaultVideoTrack), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'no longer emits the default video track ' + 'when the camera is disposed', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.onEndedStreamController.isClosed, + isTrue, + ); + }); + }); + }); }); } diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index d48df122277f..9ab8c511f753 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -563,24 +563,34 @@ void main() { late Camera camera; late VideoElement videoElement; + late StreamController errorStreamController, abortStreamController; + late StreamController endedStreamController; + setUp(() { camera = MockCamera(); videoElement = MockVideoElement(); - when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError) - .thenAnswer((_) => FakeElementStream(Stream.empty())); - when(() => videoElement.onAbort) - .thenAnswer((_) => FakeElementStream(Stream.empty())); - }); + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); - testWidgets('initializes and plays the camera', (tester) async { when(camera.getVideoSize).thenAnswer( (_) => Future.value(Size(10, 10)), ); when(camera.initialize).thenAnswer((_) => Future.value()); when(camera.play).thenAnswer((_) => Future.value()); + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + when(() => camera.onEnded) + .thenAnswer((_) => endedStreamController.stream); + }); + + testWidgets('initializes and plays the camera', (tester) async { // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; @@ -590,6 +600,32 @@ void main() { verify(camera.play).called(1); }); + testWidgets('starts listening to the camera video error and abort events', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect(errorStreamController.hasListener, isFalse); + expect(abortStreamController.hasListener, isFalse); + + await CameraPlatform.instance.initializeCamera(cameraId); + + expect(errorStreamController.hasListener, isTrue); + expect(abortStreamController.hasListener, isTrue); + }); + + testWidgets('starts listening to the camera ended events', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect(endedStreamController.hasListener, isFalse); + + await CameraPlatform.instance.initializeCamera(cameraId); + + expect(endedStreamController.hasListener, isTrue); + }); + group('throws PlatformException', () { testWidgets( 'with notFound error ' @@ -1610,6 +1646,37 @@ void main() { }); group('dispose', () { + late Camera camera; + late VideoElement videoElement; + + late StreamController errorStreamController, abortStreamController; + late StreamController endedStreamController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); + + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + when(camera.dispose).thenAnswer((_) => Future.value()); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + when(() => camera.onEnded) + .thenAnswer((_) => endedStreamController.stream); + }); + testWidgets('disposes the correct camera', (tester) async { const firstCameraId = 0; const secondCameraId = 1; @@ -1642,38 +1709,26 @@ void main() { ); }); - testWidgets('cancels camera video and abort error subscriptions', + testWidgets('cancels the camera video error and abort subscriptions', (tester) async { - final camera = MockCamera(); - final videoElement = MockVideoElement(); - - final errorStreamController = StreamController(); - final abortStreamController = StreamController(); + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); - when(camera.initialize).thenAnswer((_) => Future.value()); - when(camera.play).thenAnswer((_) => Future.value()); + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.dispose(cameraId); - when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError) - .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort) - .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + expect(errorStreamController.hasListener, isFalse); + expect(abortStreamController.hasListener, isFalse); + }); + testWidgets('cancels the camera ended subscriptions', (tester) async { // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; await CameraPlatform.instance.initializeCamera(cameraId); - - expect(errorStreamController.hasListener, isTrue); - expect(abortStreamController.hasListener, isTrue); - await CameraPlatform.instance.dispose(cameraId); - expect(errorStreamController.hasListener, isFalse); - expect(abortStreamController.hasListener, isFalse); + expect(endedStreamController.hasListener, isFalse); }); group('throws PlatformException', () { @@ -1749,6 +1804,36 @@ void main() { }); group('events', () { + late Camera camera; + late VideoElement videoElement; + + late StreamController errorStreamController, abortStreamController; + late StreamController endedStreamController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); + + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + when(() => camera.onEnded) + .thenAnswer((_) => endedStreamController.stream); + }); + testWidgets( 'onCameraInitialized emits a CameraInitializedEvent ' 'on initializeCamera', (tester) async { @@ -1805,46 +1890,40 @@ void main() { ); }); - testWidgets('onCameraClosing throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.onCameraClosing(cameraId), - throwsUnimplementedError, - ); - }); + testWidgets( + 'onCameraClosing emits a CameraClosingEvent ' + 'on the camera ended event', (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; - group('onCameraError', () { - late Camera camera; - late VideoElement videoElement; + final Stream eventStream = + CameraPlatform.instance.onCameraClosing(cameraId); - late StreamController errorStreamController, - abortStreamController; + final streamQueue = StreamQueue(eventStream); - setUp(() { - camera = MockCamera(); - videoElement = MockVideoElement(); + await CameraPlatform.instance.initializeCamera(cameraId); - errorStreamController = StreamController(); - abortStreamController = StreamController(); + endedStreamController.add(MockMediaStreamTrack()); - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); - when(camera.initialize).thenAnswer((_) => Future.value()); - when(camera.play).thenAnswer((_) => Future.value()); + expect( + await streamQueue.next, + equals( + CameraClosingEvent(cameraId), + ), + ); - when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError).thenAnswer( - (_) => FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort).thenAnswer( - (_) => FakeElementStream(abortStreamController.stream)); + await streamQueue.cancel(); + }); + group('onCameraError', () { + setUp(() { // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; }); testWidgets( 'emits a CameraErrorEvent ' - 'on initialize video error ' + 'on the camera video error event ' 'with a message', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1879,7 +1958,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on initialize video error ' + 'on the camera video error event ' 'with no message', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1910,7 +1989,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on initialize abort error', (tester) async { + 'on the camera video abort event', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index c1343ceccf49..74d8546fbb12 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.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:html' as html; import 'dart:ui'; @@ -67,6 +68,23 @@ class Camera { /// Initialized in [initialize] and [play], reset in [stop]. html.MediaStream? stream; + /// The stream of the camera video tracks that have ended playing. + /// + /// This occurs when there is no more camera stream data, e.g. + /// the user has stopped the stream by changing the camera device, + /// revoked the camera permissions or ejected the camera device. + /// + /// MediaStreamTrack.onended: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/onended + Stream get onEnded => onEndedStreamController.stream; + + /// The stream controller for the [onEnded] stream. + @visibleForTesting + final onEndedStreamController = + StreamController.broadcast(); + + StreamSubscription? _onEndedSubscription; + /// The camera flash mode. @visibleForTesting FlashMode? flashMode; @@ -80,6 +98,7 @@ class Camera { /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. + /// Emits the camera default video track on the [onEnded] stream when it ends. Future initialize() async { stream = await _cameraService.getMediaStreamForOptions( options, @@ -103,6 +122,16 @@ class Camera { ..muted = !options.audio.enabled ..srcObject = stream ..setAttribute('playsinline', ''); + + final videoTracks = stream!.getVideoTracks(); + + if (videoTracks.isNotEmpty) { + final defaultVideoTrack = videoTracks.first; + + _onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) { + onEndedStreamController.add(defaultVideoTrack); + }); + } } /// Starts the camera stream. @@ -126,7 +155,12 @@ class Camera { /// Stops the camera stream and resets the camera source. void stop() { - final tracks = videoElement.srcObject?.getTracks(); + final videoTracks = stream!.getVideoTracks(); + if (videoTracks.isNotEmpty) { + onEndedStreamController.add(videoTracks.first); + } + + final tracks = stream?.getTracks(); if (tracks != null) { for (final track in tracks) { track.stop(); @@ -303,7 +337,7 @@ class Camera { /// Disposes the camera by stopping the camera stream /// and reloading the camera source. - void dispose() { + Future dispose() async { /// Stop the camera stream. stop(); @@ -311,6 +345,11 @@ class Camera { videoElement ..srcObject = null ..load(); + + await _onEndedSubscription?.cancel(); + _onEndedSubscription = null; + + await onEndedStreamController.close(); } /// Applies default styles to the video [element]. diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 8b131f5d4f6e..19ee43f36660 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -61,6 +61,9 @@ class CameraPlugin extends CameraPlatform { final _cameraVideoAbortSubscriptions = >{}; + final _cameraEndedSubscriptions = + >{}; + /// Returns a stream of camera events for the given [cameraId]. Stream _cameraEvents(int cameraId) => cameraEventStreamController.stream @@ -273,6 +276,15 @@ class CameraPlugin extends CameraPlatform { await camera.play(); + // Add camera's closing events to the camera events stream. + // The onEnded stream fires when there is no more camera stream data. + _cameraEndedSubscriptions[cameraId] = + camera.onEnded.listen((html.MediaStreamTrack _) { + cameraEventStreamController.add( + CameraClosingEvent(cameraId), + ); + }); + final cameraSize = await camera.getVideoSize(); cameraEventStreamController.add( @@ -313,7 +325,7 @@ class CameraPlugin extends CameraPlatform { @override Stream onCameraClosing(int cameraId) { - throw UnimplementedError('onCameraClosing() is not implemented.'); + return _cameraEvents(cameraId).whereType(); } @override @@ -548,13 +560,15 @@ class CameraPlugin extends CameraPlatform { @override Future dispose(int cameraId) async { try { - getCamera(cameraId).dispose(); + await getCamera(cameraId).dispose(); await _cameraVideoErrorSubscriptions[cameraId]?.cancel(); await _cameraVideoAbortSubscriptions[cameraId]?.cancel(); + await _cameraEndedSubscriptions[cameraId]?.cancel(); cameras.remove(cameraId); _cameraVideoErrorSubscriptions.remove(cameraId); _cameraVideoAbortSubscriptions.remove(cameraId); + _cameraEndedSubscriptions.remove(cameraId); } on html.DomException catch (e) { throw PlatformException(code: e.name, message: e.message); } From 79595de6752d75e226d601e275776e709e343f69 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 26 Aug 2021 09:10:15 -0400 Subject: [PATCH 220/364] [flutter_plugin_tool] Fix CHANGELOG validation failure summary (#4266) The error summary for a CHANGELOG validation failure was written when the only thing being checked was that the versions matched, but now there are other ways to fail as well (i.e., leaving NEXT). This fixes the summary message to be more generic so that it doesn't mislead people who hit validation failures. While adding the test for this message, I discovered that almost all of the tests were actually talking to pub.dev, causing their behavior to in some cases depend on whether a package with that name happened to have been published, and if so what its version was. In order to make the tests hermetic and predictable, this fixes that by making all tests use a mock HTTP client. --- .../tool/lib/src/version_check_command.dart | 2 +- .../tool/test/version_check_command_test.dart | 66 +++++++------------ 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 67a81b967a8e..6b49c40d66bb 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -178,7 +178,7 @@ class VersionCheckCommand extends PackageLoopingCommand { if (!(await _validateChangelogVersion(package, pubspec: pubspec, pubspecVersionChanged: versionChanged))) { - errors.add('pubspec.yaml and CHANGELOG.md have different versions'); + errors.add('CHANGELOG.md failed validation.'); } return errors.isEmpty diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 7765073feb08..9ab7c57089a3 100644 --- a/script/tool/test/version_check_command_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -54,11 +54,15 @@ void main() { late List> gitDirCommands; Map gitShowResponses; late MockGitDir gitDir; + // Ignored if mockHttpResponse is set. + int mockHttpStatus; + Map? mockHttpResponse; setUp(() { fileSystem = MemoryFileSystem(); mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); + gitDirCommands = >[]; gitShowResponses = {}; gitDir = MockGitDir(); @@ -81,9 +85,21 @@ void main() { } return Future.value(mockProcessResult); }); + + // Default to simulating the plugin never having been published. + mockHttpStatus = 404; + mockHttpResponse = null; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(mockHttpResponse), + mockHttpResponse == null ? mockHttpStatus : 200); + }); + processRunner = RecordingProcessRunner(); final VersionCheckCommand command = VersionCheckCommand(packagesDir, - processRunner: processRunner, platform: mockPlatform, gitDir: gitDir); + processRunner: processRunner, + platform: mockPlatform, + gitDir: gitDir, + httpClient: mockClient); runner = CommandRunner( 'version_check_command', 'Test for $VersionCheckCommand'); @@ -456,7 +472,9 @@ void main() { output, containsAllInOrder([ contains('When bumping the version for release, the NEXT section ' - 'should be incorporated into the new version\'s release notes.') + 'should be incorporated into the new version\'s release notes.'), + contains('plugin:\n' + ' CHANGELOG.md failed validation.'), ]), ); }); @@ -497,7 +515,7 @@ void main() { }); test('allows valid against pub', () async { - const Map httpResponse = { + mockHttpResponse = { 'name': 'some_package', 'versions': [ '0.0.1', @@ -505,15 +523,6 @@ void main() { '1.0.0', ], }; - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response(json.encode(httpResponse), 200); - }); - final VersionCheckCommand command = VersionCheckCommand(packagesDir, - processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); - - runner = CommandRunner( - 'version_check_command', 'Test for $VersionCheckCommand'); - runner.addCommand(command); createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { @@ -531,22 +540,13 @@ void main() { }); test('denies invalid against pub', () async { - const Map httpResponse = { + mockHttpResponse = { 'name': 'some_package', 'versions': [ '0.0.1', '0.0.2', ], }; - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response(json.encode(httpResponse), 200); - }); - final VersionCheckCommand command = VersionCheckCommand(packagesDir, - processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); - - runner = CommandRunner( - 'version_check_command', 'Test for $VersionCheckCommand'); - runner.addCommand(command); createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { @@ -578,15 +578,7 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N test( 'throw and print error message if http request failed when checking against pub', () async { - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response('xx', 400); - }); - final VersionCheckCommand command = VersionCheckCommand(packagesDir, - processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); - - runner = CommandRunner( - 'version_check_command', 'Test for $VersionCheckCommand'); - runner.addCommand(command); + mockHttpStatus = 400; createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { @@ -609,7 +601,7 @@ ${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: N contains(''' ${indentation}Error fetching version on pub for plugin. ${indentation}HTTP Status 400 -${indentation}HTTP response: xx +${indentation}HTTP response: null ''') ]), ); @@ -617,15 +609,7 @@ ${indentation}HTTP response: xx test('when checking against pub, allow any version if http status is 404.', () async { - final MockClient mockClient = MockClient((http.Request request) async { - return http.Response('xx', 404); - }); - final VersionCheckCommand command = VersionCheckCommand(packagesDir, - processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); - - runner = CommandRunner( - 'version_check_command', 'Test for $VersionCheckCommand'); - runner.addCommand(command); + mockHttpStatus = 404; createFakePlugin('plugin', packagesDir, version: '2.0.0'); gitShowResponses = { From 9cea8db971ac0d0240736d2358a6ade0d9950652 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Thu, 26 Aug 2021 10:26:06 -0700 Subject: [PATCH 221/364] [ci.yaml] Add auto-roller (#4270) --- .ci.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.ci.yaml b/.ci.yaml index c2b7deebab14..86bc72c7aebf 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -44,3 +44,11 @@ targets: {"dependency": "vs_build"} ] scheduler: luci + + - name: Linux ci_yaml plugins roller + recipe: infra/ci_yaml + bringup: true + timeout: 30 + scheduler: luci + runIf: + - .ci.yaml From ee8355bdcff82cd2ab887141a0eb56fb5a71bc64 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 26 Aug 2021 15:01:06 -0400 Subject: [PATCH 222/364] [flutter_plugin_tools] Check 'implements' for unpublished plugins (#4273) --- packages/camera/camera_web/pubspec.yaml | 1 + .../tool/lib/src/pubspec_check_command.dart | 19 +++--- .../tool/test/pubspec_check_command_test.dart | 64 +++++++++++++++++++ 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index 822af60a979b..70194d9037d4 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -15,6 +15,7 @@ environment: flutter: plugin: + implements: camera platforms: web: pluginClass: CameraPlugin diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index def2adaf2788..29f9ea733a03 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -97,6 +97,16 @@ class PubspecCheckCommand extends PackageLoopingCommand { printError('$listIndentation${sectionOrder.join('\n$listIndentation')}'); } + if (isPlugin) { + final String? error = _checkForImplementsError(pubspec, package: package); + if (error != null) { + printError('$indentation$error'); + passing = false; + } + } + + // Ignore metadata that's only relevant for published packages if the + // packages is not intended for publishing. if (pubspec.publishTo != 'none') { final List repositoryErrors = _checkForRepositoryLinkErrors(pubspec, package: package); @@ -114,15 +124,6 @@ class PubspecCheckCommand extends PackageLoopingCommand { '${indentation * 2}$_expectedIssueLinkFormat'); passing = false; } - - if (isPlugin) { - final String? error = - _checkForImplementsError(pubspec, package: package); - if (error != null) { - printError('$indentation$error'); - passing = false; - } - } } return passing; diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 833f7b601e50..c5d36013c40b 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -55,6 +55,7 @@ void main() { String? repositoryPackagesDirRelativePath, bool includeHomepage = false, bool includeIssueTracker = true, + bool publishable = true, }) { final String repositoryPath = repositoryPackagesDirRelativePath ?? name; final String repoLink = 'https://github.com/flutter/' @@ -69,6 +70,7 @@ ${includeRepository ? 'repository: $repoLink' : ''} ${includeHomepage ? 'homepage: $repoLink' : ''} ${includeIssueTracker ? 'issue_tracker: $issueTrackerLink' : ''} version: 1.0.0 +${publishable ? '' : 'publish_to: \'none\''} '''; } @@ -567,5 +569,67 @@ ${devDependenciesSection()} ]), ); }); + + test('validates some properties even for unpublished packages', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin_a_foo', packagesDir.childDirectory('plugin_a')); + + // Environment section is in the wrong location. + // Missing 'implements'. + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin_a_foo', isPlugin: true, publishable: false)} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +${environmentSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Major sections should follow standard repository ordering:'), + contains('Missing "implements: plugin_a" in "plugin" section.'), + ]), + ); + }); + + test('ignores some checks for unpublished packages', () async { + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); + + // Missing metadata that is only useful for published packages, such as + // repository and issue tracker. + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection( + 'plugin', + isPlugin: true, + publishable: false, + includeRepository: false, + includeIssueTracker: false, + )} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + final List output = + await runCapturingPrint(runner, ['pubspec-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin...'), + contains('No issues found!'), + ]), + ); + }); }); } From 00ace648e5b6116f2b7dcc7a2f14c3a299ab1c69 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 26 Aug 2021 15:05:28 -0400 Subject: [PATCH 223/364] [flutter_plugin_tools] Move publish tests to RecordingProcessRunner (#4269) Replaces almost all of the `TestProcessRunner`, which was specific to the `publish` tests, with the repo-standard `RecordingProcessRunner` (which now has most of the capabilities these tests need). This finishes aligning these tests with the rest of the repository tests, so they will be easier to maintain as part of the overall repository. To support this, `RecordingProcessRunner` was modified slightly to return a succeeding, no-output process by default for `start`. That makes it consistent with its existing `run` behavior, so is a good change in general. --- .../tool/test/publish_check_command_test.dart | 17 - .../test/publish_plugin_command_test.dart | 333 ++++++++---------- script/tool/test/util.dart | 5 +- 3 files changed, 152 insertions(+), 203 deletions(-) diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 65b0cb54547c..e1ab0e224e44 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -49,11 +49,6 @@ void main() { final Directory plugin2Dir = createFakePlugin('plugin_tools_test_package_b', packagesDir); - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess(), - MockProcess(), - ]; - await runCapturingPrint(runner, ['publish-check']); expect( @@ -87,10 +82,6 @@ void main() { final Directory dir = createFakePlugin('c', packagesDir); await dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess(), - ]; - expect(() => runCapturingPrint(runner, ['publish-check']), throwsA(isA())); }); @@ -245,10 +236,6 @@ void main() { createFakePlugin('no_publish_a', packagesDir, version: '0.1.0'); createFakePlugin('no_publish_b', packagesDir, version: '0.2.0'); - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess(), - ]; - final List output = await runCapturingPrint( runner, ['publish-check', '--machine']); @@ -318,10 +305,6 @@ void main() { await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess(), - ]; - bool hasError = false; final List output = await runCapturingPrint( runner, ['publish-check', '--machine'], diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 40018b6edb61..663c2633a9db 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -10,7 +10,6 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; -import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -23,13 +22,11 @@ import 'mocks.dart'; import 'util.dart'; void main() { - const String testPluginName = 'foo'; + final String flutterCommand = getFlutterCommand(const LocalPlatform()); late Directory packagesDir; - late Directory pluginDir; late MockGitDir gitDir; late TestProcessRunner processRunner; - late RecordingProcessRunner gitProcessRunner; late CommandRunner commandRunner; late MockStdin mockStdin; late FileSystem fileSystem; @@ -44,25 +41,21 @@ void main() { setUp(() async { fileSystem = MemoryFileSystem(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); - // TODO(stuartmorgan): Move this from setup to individual tests. - pluginDir = - createFakePlugin(testPluginName, packagesDir, examples: []); - assert(pluginDir != null && pluginDir.existsSync()); - gitProcessRunner = RecordingProcessRunner(); + processRunner = TestProcessRunner(); gitDir = MockGitDir(); when(gitDir.path).thenReturn(packagesDir.parent.path); when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) .thenAnswer((Invocation invocation) { final List arguments = invocation.positionalArguments[0]! as List; - // Attach the first argument to the command to make targeting the mock - // results easier. + // Route git calls through the process runner, to make mock output + // consistent with outer processes. Attach the first argument to the + // command to make targeting the mock results easier. final String gitCommand = arguments.removeAt(0); - return gitProcessRunner.run('git-$gitCommand', arguments); + return processRunner.run('git-$gitCommand', arguments); }); - processRunner = TestProcessRunner(); mockStdin = MockStdin(); commandRunner = CommandRunner('tester', '') ..addCommand(PublishPluginCommand(packagesDir, @@ -99,18 +92,17 @@ void main() { }); test('refuses to proceed with dirty files', () async { - gitProcessRunner.mockProcessesForExecutable['git-status'] = [ + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + + processRunner.mockProcessesForExecutable['git-status'] = [ MockProcess(stdout: '?? ${pluginDir.childFile('tmp').path}\n') ]; Error? commandError; - final List output = await runCapturingPrint( - commandRunner, [ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags' - ], errorHandler: (Error e) { + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--package', 'foo', '--no-push-tags'], + errorHandler: (Error e) { commandError = e; }); @@ -128,13 +120,15 @@ void main() { }); test('fails immediately if the remote doesn\'t exist', () async { - gitProcessRunner.mockProcessesForExecutable['git-remote'] = [ + createFakePlugin('foo', packagesDir, examples: []); + + processRunner.mockProcessesForExecutable['git-remote'] = [ MockProcess(exitCode: 1), ]; Error? commandError; - final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--package', testPluginName], + final List output = await runCapturingPrint( + commandRunner, ['publish-plugin', '--package', 'foo'], errorHandler: (Error e) { commandError = e; }); @@ -149,19 +143,18 @@ void main() { }); test("doesn't validate the remote if it's not pushing tags", () async { + createFakePlugin('foo', packagesDir, examples: []); + // Checking the remote should fail. - gitProcessRunner.mockProcessesForExecutable['git-remote'] = [ + processRunner.mockProcessesForExecutable['git-remote'] = [ MockProcess(exitCode: 1), ]; - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - - final List output = - await runCapturingPrint(commandRunner, [ + final List output = await runCapturingPrint( + commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', '--no-tag-release' ]); @@ -169,17 +162,15 @@ void main() { expect( output, containsAllInOrder([ - contains('Running `pub publish ` in /packages/$testPluginName...'), + contains('Running `pub publish ` in /packages/foo...'), contains('Package published!'), - contains('Released [$testPluginName] successfully.'), + contains('Released [foo] successfully.'), ])); }); test('can publish non-flutter package', () async { const String packageName = 'a_package'; createFakePackage(packageName, packagesDir); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; final List output = await runCapturingPrint( commandRunner, [ @@ -204,15 +195,21 @@ void main() { group('Publishes package', () { test('while showing all output from pub publish to the user', () async { - processRunner.mockPublishStdout = 'Foo'; - processRunner.mockPublishStderr = 'Bar'; - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); - final List output = - await runCapturingPrint(commandRunner, [ + processRunner.mockProcessesForExecutable[flutterCommand] = [ + MockProcess( + stdout: 'Foo', + stderr: 'Bar', + stdoutEncoding: utf8, + stderrEncoding: utf8) // pub publish + ]; + + final List output = await runCapturingPrint( + commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', '--no-tag-release' ]); @@ -226,13 +223,14 @@ void main() { }); test('forwards input from the user to `pub publish`', () async { + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.mockUserInputs.add(utf8.encode('user input')); - processRunner.mockPublishCompleteCode = 0; await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', '--no-tag-release' ]); @@ -242,35 +240,38 @@ void main() { }); test('forwards --pub-publish-flags to pub publish', () async { - processRunner.mockPublishCompleteCode = 0; + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', '--no-tag-release', '--pub-publish-flags', '--dry-run,--server=foo' ]); - expect(processRunner.mockPublishArgs.length, 4); - expect(processRunner.mockPublishArgs[0], 'pub'); - expect(processRunner.mockPublishArgs[1], 'publish'); - expect(processRunner.mockPublishArgs[2], '--dry-run'); - expect(processRunner.mockPublishArgs[3], '--server=foo'); + expect( + processRunner.recordedCalls, + contains(ProcessCall( + flutterCommand, + const ['pub', 'publish', '--dry-run', '--server=foo'], + pluginDir.path))); }); test( '--skip-confirmation flag automatically adds --force to --pub-publish-flags', () async { - processRunner.mockPublishCompleteCode = 0; _createMockCredentialFile(); + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', '--no-tag-release', '--skip-confirmation', @@ -278,22 +279,27 @@ void main() { '--server=foo' ]); - expect(processRunner.mockPublishArgs.length, 4); - expect(processRunner.mockPublishArgs[0], 'pub'); - expect(processRunner.mockPublishArgs[1], 'publish'); - expect(processRunner.mockPublishArgs[2], '--server=foo'); - expect(processRunner.mockPublishArgs[3], '--force'); + expect( + processRunner.recordedCalls, + contains(ProcessCall( + flutterCommand, + const ['pub', 'publish', '--server=foo', '--force'], + pluginDir.path))); }); test('throws if pub publish fails', () async { - processRunner.mockPublishCompleteCode = 128; + createFakePlugin('foo', packagesDir, examples: []); + + processRunner.mockProcessesForExecutable[flutterCommand] = [ + MockProcess(exitCode: 128) // pub publish + ]; Error? commandError; final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', '--no-tag-release', ], errorHandler: (Error e) { @@ -309,18 +315,21 @@ void main() { }); test('publish, dry run', () async { + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--dry-run', '--no-push-tags', '--no-tag-release', ]); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); expect( @@ -335,30 +344,31 @@ void main() { group('Tags release', () { test('with the version and name from the pubspec.yaml', () async { - processRunner.mockPublishCompleteCode = 0; - + createFakePlugin('foo', packagesDir, examples: []); await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', ]); - expect( - gitProcessRunner.recordedCalls, - contains(const ProcessCall( - 'git-tag', ['$testPluginName-v0.0.1'], null))); + expect(processRunner.recordedCalls, + contains(const ProcessCall('git-tag', ['foo-v0.0.1'], null))); }); test('only if publishing succeeded', () async { - processRunner.mockPublishCompleteCode = 128; + createFakePlugin('foo', packagesDir, examples: []); + + processRunner.mockProcessesForExecutable[flutterCommand] = [ + MockProcess(exitCode: 128) // pub publish + ]; Error? commandError; final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-push-tags', ], errorHandler: (Error e) { commandError = e; @@ -371,7 +381,7 @@ void main() { contains('Publish foo failed.'), ])); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, isNot(contains( const ProcessCall('git-tag', ['foo-v0.0.1'], null)))); }); @@ -379,7 +389,8 @@ void main() { group('Pushes tags', () { test('requires user confirmation', () async { - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'help'; Error? commandError; @@ -387,7 +398,7 @@ void main() { await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', ], errorHandler: (Error e) { commandError = e; }); @@ -397,61 +408,63 @@ void main() { }); test('to upstream by default', () async { - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', ]); expect( - gitProcessRunner.recordedCalls, - contains(const ProcessCall('git-push', - ['upstream', '$testPluginName-v0.0.1'], null))); + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'foo-v0.0.1'], null))); expect( output, containsAllInOrder([ - contains('Released [$testPluginName] successfully.'), + contains('Released [foo] successfully.'), ])); }); test('does not ask for user input if the --skip-confirmation flag is on', () async { - processRunner.mockPublishCompleteCode = 0; _createMockCredentialFile(); + createFakePlugin('foo', packagesDir, examples: []); final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--skip-confirmation', '--package', - testPluginName, + 'foo', ]); expect( - gitProcessRunner.recordedCalls, - contains(const ProcessCall('git-push', - ['upstream', '$testPluginName-v0.0.1'], null))); + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'foo-v0.0.1'], null))); expect( output, containsAllInOrder([ - contains('Released [$testPluginName] successfully.'), + contains('Released [foo] successfully.'), ])); }); test('to upstream by default, dry run', () async { - // Immediately return 1 when running `pub publish`. If dry-run does not work, test should throw. - processRunner.mockPublishCompleteCode = 1; + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--package', testPluginName, '--dry-run']); + ['publish-plugin', '--package', 'foo', '--dry-run']); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); expect( @@ -459,57 +472,58 @@ void main() { containsAllInOrder([ '=============== DRY RUN ===============', 'Running `pub publish ` in ${pluginDir.path}...\n', - 'Tagging release $testPluginName-v0.0.1...', + 'Tagging release foo-v0.0.1...', 'Pushing tag to upstream...', 'Done!' ])); }); test('to different remotes based on a flag', () async { - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--remote', 'origin', ]); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( - 'git-push', ['origin', '$testPluginName-v0.0.1'], null))); + 'git-push', ['origin', 'foo-v0.0.1'], null))); expect( output, containsAllInOrder([ - contains('Released [$testPluginName] successfully.'), + contains('Released [foo] successfully.'), ])); }); test('only if tagging and pushing to remotes are both enabled', () async { - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', '--package', - testPluginName, + 'foo', '--no-tag-release', ]); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); expect( output, containsAllInOrder([ - contains('Running `pub publish ` in /packages/$testPluginName...'), + contains('Running `pub publish ` in /packages/foo...'), contains('Package published!'), - contains('Released [$testPluginName] successfully.'), + contains('Released [foo] successfully.'), ])); }); }); @@ -553,13 +567,11 @@ void main() { 'plugin2', packagesDir.childDirectory('plugin2'), ); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') ]; - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint(commandRunner, @@ -576,11 +588,11 @@ void main() { 'Done!' ])); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin1-v0.0.1'], null))); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin2-v0.0.1'], null))); }); @@ -634,17 +646,15 @@ void main() { // Git results for plugin0 having been released already, and plugin1 and // plugin2 being new. - gitProcessRunner.mockProcessesForExecutable['git-tag'] = [ + processRunner.mockProcessesForExecutable['git-tag'] = [ MockProcess(stdout: 'plugin0-v0.0.1\n') ]; - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') ]; - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint(commandRunner, @@ -661,11 +671,11 @@ void main() { 'Done!' ])); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin1-v0.0.1'], null))); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin2-v0.0.1'], null))); }); @@ -706,7 +716,7 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') @@ -737,7 +747,7 @@ void main() { 'Done!' ])); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); }); @@ -781,14 +791,12 @@ void main() { 'plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') ]; - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; final List output2 = await runCapturingPrint(commandRunner, @@ -804,11 +812,11 @@ void main() { 'Done!' ])); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin1-v0.0.2'], null))); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin2-v0.0.2'], null))); }); @@ -854,14 +862,12 @@ void main() { createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); pluginDir2.deleteSync(recursive: true); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') ]; - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; mockStdin.readLineOutput = 'y'; final List output2 = await runCapturingPrint(commandRunner, @@ -877,7 +883,7 @@ void main() { 'Done!' ])); expect( - gitProcessRunner.recordedCalls, + processRunner.recordedCalls, contains(const ProcessCall( 'git-push', ['upstream', 'plugin1-v0.0.2'], null))); }); @@ -922,12 +928,12 @@ void main() { 'plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') ]; - gitProcessRunner.mockProcessesForExecutable['git-tag'] = [ + processRunner.mockProcessesForExecutable['git-tag'] = [ MockProcess( stdout: 'plugin1-v0.0.2\n' 'plugin2-v0.0.2\n') @@ -949,7 +955,7 @@ void main() { ])); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); }); @@ -995,7 +1001,7 @@ void main() { 'plugin2', packagesDir.childDirectory('plugin2'), version: '0.0.2'); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' '${pluginDir2.childFile('pubspec.yaml').path}\n') @@ -1020,7 +1026,7 @@ void main() { 'However, the git release tag for this version (plugin2-v0.0.2) is not found.'), ])); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); }); @@ -1032,7 +1038,7 @@ void main() { final Directory pluginDir2 = createFakePlugin('plugin2', packagesDir.childDirectory('plugin2')); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess( stdout: '${pluginDir1.childFile('plugin1.dart').path}\n' '${pluginDir2.childFile('plugin2.dart').path}\n') @@ -1050,7 +1056,7 @@ void main() { 'Done!' ])); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); }); @@ -1081,7 +1087,7 @@ void main() { final Directory flutterPluginTools = createFakePlugin('flutter_plugin_tools', packagesDir); - gitProcessRunner.mockProcessesForExecutable['git-diff'] = [ + processRunner.mockProcessesForExecutable['git-diff'] = [ MockProcess(stdout: flutterPluginTools.childFile('pubspec.yaml').path) ]; @@ -1101,75 +1107,41 @@ void main() { ), isFalse); expect( - gitProcessRunner.recordedCalls + processRunner.recordedCalls .map((ProcessCall call) => call.executable), isNot(contains('git-push'))); }); }); } -class TestProcessRunner extends ProcessRunner { +/// An extension of [RecordingProcessRunner] that stores 'flutter pub publish' +/// calls so that their input streams can be checked in tests. +class TestProcessRunner extends RecordingProcessRunner { // Most recent returned publish process. late MockProcess mockPublishProcess; - final List mockPublishArgs = []; - - String? mockPublishStdout; - String? mockPublishStderr; - int mockPublishCompleteCode = 0; - - @override - Future run( - String executable, - List args, { - Directory? workingDir, - bool exitOnError = false, - bool logOnError = false, - Encoding stdoutEncoding = io.systemEncoding, - Encoding stderrEncoding = io.systemEncoding, - }) async { - final io.ProcessResult result = io.Process.runSync(executable, args, - workingDirectory: workingDir?.path); - if (result.exitCode != 0) { - throw ToolExit(result.exitCode); - } - return result; - } @override Future start(String executable, List args, {Directory? workingDirectory}) async { - /// Never actually publish anything. Start is always and only used for this - /// since it returns something we can route stdin through. - assert(executable == getFlutterCommand(const LocalPlatform()) && + final io.Process process = + await super.start(executable, args, workingDirectory: workingDirectory); + if (executable == getFlutterCommand(const LocalPlatform()) && args.isNotEmpty && args[0] == 'pub' && - args[1] == 'publish'); - mockPublishArgs.addAll(args); - - mockPublishProcess = MockProcess( - exitCode: mockPublishCompleteCode, - stdout: mockPublishStdout, - stderr: mockPublishStderr, - stdoutEncoding: utf8, - stderrEncoding: utf8, - ); - return mockPublishProcess; + args[1] == 'publish') { + mockPublishProcess = process as MockProcess; + } + return process; } } class MockStdin extends Mock implements io.Stdin { List> mockUserInputs = >[]; - late StreamController> _controller; + final StreamController> _controller = StreamController>(); String? readLineOutput; @override Stream transform(StreamTransformer, S> streamTransformer) { - // In the test context, only one `PublishPluginCommand` object is created for a single test case. - // However, sometimes, we need to run multiple commands in a single test case. - // In such situation, this `MockStdin`'s StreamController might be listened to more than once, which is not allowed. - // - // Create a new controller every time so this Stdin could be listened to multiple times. - _controller = StreamController>(); mockUserInputs.forEach(_addUserInputsToSteam); return _controller.stream.transform(streamTransformer); } @@ -1189,12 +1161,3 @@ class MockStdin extends Mock implements io.Stdin { void _addUserInputsToSteam(List input) => _controller.add(input); } - -class MockProcessResult extends Mock implements io.ProcessResult { - MockProcessResult({int exitCode = 0}) : _exitCode = exitCode; - - final int _exitCode; - - @override - int get exitCode => _exitCode; -} diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 05aebe82fd79..7bd94fb66e22 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -17,6 +17,8 @@ import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:quiver/collection.dart'; +import 'mocks.dart'; + /// Returns the exe name that command will use when running Flutter on /// [platform]. String getFlutterCommand(Platform platform) => @@ -320,7 +322,8 @@ class RecordingProcessRunner extends ProcessRunner { Future start(String executable, List args, {Directory? workingDirectory}) async { recordedCalls.add(ProcessCall(executable, args, workingDirectory?.path)); - return Future.value(_getProcessToReturn(executable)); + return Future.value( + _getProcessToReturn(executable) ?? MockProcess()); } io.Process? _getProcessToReturn(String executable) { From a011b309b77c6b13c844c1dc6ed081327214573a Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 26 Aug 2021 15:07:33 -0400 Subject: [PATCH 224/364] [flutter_plugin_tool] Add support for building UWP plugins (#4047) This allows building UWP plugin examples with `build-examples --winuwp`. As with previous pre-stable-template desktop support, this avoids the issue of unstable app templates by running `flutter create` on the fly before trying to build, so a template that will bitrot doesn't need to be checked in. Also adds no-op "support" for `drive-examples --winuwp`, with warnings about it not doing anything. This is to handle the fact that the LUCI recipe is shared between Win32 and UWP, and didn't conditionalize `drive`. Rather than change that, then change it back later, this just adds the no-op support now (since changing the tooling is much easier than changing LUCI recipes currently). This required some supporting tool changes: - Adds the ability to check for the new platform variants in a pubspec - Adds the ability to write test pubspecs that include variants, for testing Part of https://github.com/flutter/flutter/issues/82817 --- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/build_examples_command.dart | 64 ++++++-- script/tool/lib/src/common/core.dart | 37 +++-- script/tool/lib/src/common/plugin_utils.dart | 39 ++++- .../tool/lib/src/drive_examples_command.dart | 26 ++- .../test/build_examples_command_test.dart | 129 ++++++++++++--- .../tool/test/common/plugin_utils_test.dart | 148 +++++++++++++----- .../create_all_plugins_app_command_test.dart | 2 +- .../test/drive_examples_command_test.dart | 112 ++++++++----- .../tool/test/lint_android_command_test.dart | 16 +- .../tool/test/native_test_command_test.dart | 124 +++++++-------- script/tool/test/test_command_test.dart | 4 +- script/tool/test/util.dart | 89 ++++++++--- .../tool/test/xcode_analyze_command_test.dart | 42 ++--- 14 files changed, 589 insertions(+), 244 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 1881d1bb6689..a32fb0016cb3 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -4,6 +4,7 @@ - Added a new `android-lint` command to lint Android plugin native code. - Pubspec validation now checks for `implements` in implementation packages. - Pubspec valitation now checks the full relative path of `repository` entries. +- `build-examples` now supports UWP plugins via a `--winuwp` flag. ## 0.5.0 diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index ac5e84b7c3c7..e441f61d5644 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -16,7 +16,16 @@ import 'common/repository_package.dart'; /// Key for APK. const String _platformFlagApk = 'apk'; -const int _exitNoPlatformFlags = 2; +const int _exitNoPlatformFlags = 3; + +// Flutter build types. These are the values passed to `flutter build `. +const String _flutterBuildTypeAndroid = 'apk'; +const String _flutterBuildTypeIos = 'ios'; +const String _flutterBuildTypeLinux = 'linux'; +const String _flutterBuildTypeMacOS = 'macos'; +const String _flutterBuildTypeWeb = 'web'; +const String _flutterBuildTypeWin32 = 'windows'; +const String _flutterBuildTypeWinUwp = 'winuwp'; /// A command to build the example applications for packages. class BuildExamplesCommand extends PackageLoopingCommand { @@ -30,6 +39,7 @@ class BuildExamplesCommand extends PackageLoopingCommand { argParser.addFlag(kPlatformMacos); argParser.addFlag(kPlatformWeb); argParser.addFlag(kPlatformWindows); + argParser.addFlag(kPlatformWinUwp); argParser.addFlag(kPlatformIos); argParser.addFlag(_platformFlagApk); argParser.addOption( @@ -46,33 +56,40 @@ class BuildExamplesCommand extends PackageLoopingCommand { _platformFlagApk: const _PlatformDetails( 'Android', pluginPlatform: kPlatformAndroid, - flutterBuildType: 'apk', + flutterBuildType: _flutterBuildTypeAndroid, ), kPlatformIos: const _PlatformDetails( 'iOS', pluginPlatform: kPlatformIos, - flutterBuildType: 'ios', + flutterBuildType: _flutterBuildTypeIos, extraBuildFlags: ['--no-codesign'], ), kPlatformLinux: const _PlatformDetails( 'Linux', pluginPlatform: kPlatformLinux, - flutterBuildType: 'linux', + flutterBuildType: _flutterBuildTypeLinux, ), kPlatformMacos: const _PlatformDetails( 'macOS', pluginPlatform: kPlatformMacos, - flutterBuildType: 'macos', + flutterBuildType: _flutterBuildTypeMacOS, ), kPlatformWeb: const _PlatformDetails( 'web', pluginPlatform: kPlatformWeb, - flutterBuildType: 'web', + flutterBuildType: _flutterBuildTypeWeb, ), kPlatformWindows: const _PlatformDetails( - 'Windows', + 'Win32', + pluginPlatform: kPlatformWindows, + pluginPlatformVariant: platformVariantWin32, + flutterBuildType: _flutterBuildTypeWin32, + ), + kPlatformWinUwp: const _PlatformDetails( + 'UWP', pluginPlatform: kPlatformWindows, - flutterBuildType: 'windows', + pluginPlatformVariant: platformVariantWinUwp, + flutterBuildType: _flutterBuildTypeWinUwp, ), }; @@ -107,7 +124,8 @@ class BuildExamplesCommand extends PackageLoopingCommand { final Set<_PlatformDetails> buildPlatforms = <_PlatformDetails>{}; final Set<_PlatformDetails> unsupportedPlatforms = <_PlatformDetails>{}; for (final _PlatformDetails platform in requestedPlatforms) { - if (pluginSupportsPlatform(platform.pluginPlatform, package)) { + if (pluginSupportsPlatform(platform.pluginPlatform, package, + variant: platform.pluginPlatformVariant)) { buildPlatforms.add(platform); } else { unsupportedPlatforms.add(platform); @@ -156,6 +174,22 @@ class BuildExamplesCommand extends PackageLoopingCommand { }) async { final String enableExperiment = getStringArg(kEnableExperiment); + // The UWP template is not yet stable, so the UWP directory + // needs to be created on the fly with 'flutter create .' + Directory? temporaryPlatformDirectory; + if (flutterBuildType == _flutterBuildTypeWinUwp) { + final Directory uwpDirectory = example.directory.childDirectory('winuwp'); + if (!uwpDirectory.existsSync()) { + print('Creating temporary winuwp folder'); + final int exitCode = await processRunner.runAndStream(flutterCommand, + ['create', '--platforms=$kPlatformWinUwp', '.'], + workingDir: example.directory); + if (exitCode == 0) { + temporaryPlatformDirectory = uwpDirectory; + } + } + } + final int exitCode = await processRunner.runAndStream( flutterCommand, [ @@ -167,6 +201,13 @@ class BuildExamplesCommand extends PackageLoopingCommand { ], workingDir: example.directory, ); + + if (temporaryPlatformDirectory != null && + temporaryPlatformDirectory.existsSync()) { + print('Cleaning up ${temporaryPlatformDirectory.path}'); + temporaryPlatformDirectory.deleteSync(recursive: true); + } + return exitCode == 0; } } @@ -176,6 +217,7 @@ class _PlatformDetails { const _PlatformDetails( this.label, { required this.pluginPlatform, + this.pluginPlatformVariant, required this.flutterBuildType, this.extraBuildFlags = const [], }); @@ -186,6 +228,10 @@ class _PlatformDetails { /// The key in a pubspec's platform: entry. final String pluginPlatform; + /// The supportedVariants key under a plugin's [pluginPlatform] entry, if + /// applicable. + final String? pluginPlatformVariant; + /// The `flutter build` build type. final String flutterBuildType; diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart index b2be8f56d172..53778eccb87f 100644 --- a/script/tool/lib/src/common/core.dart +++ b/script/tool/lib/src/common/core.dart @@ -10,24 +10,43 @@ import 'package:yaml/yaml.dart'; /// print destination. typedef Print = void Function(Object? object); -/// Key for windows platform. -const String kPlatformWindows = 'windows'; +/// Key for APK (Android) platform. +const String kPlatformAndroid = 'android'; -/// Key for macos platform. -const String kPlatformMacos = 'macos'; +/// Key for IPA (iOS) platform. +const String kPlatformIos = 'ios'; /// Key for linux platform. const String kPlatformLinux = 'linux'; -/// Key for IPA (iOS) platform. -const String kPlatformIos = 'ios'; - -/// Key for APK (Android) platform. -const String kPlatformAndroid = 'android'; +/// Key for macos platform. +const String kPlatformMacos = 'macos'; /// Key for Web platform. const String kPlatformWeb = 'web'; +/// Key for windows platform. +/// +/// Note that this corresponds to the Win32 variant for flutter commands like +/// `build` and `run`, but is a general platform containing all Windows +/// variants for purposes of the `platform` section of a plugin pubspec). +const String kPlatformWindows = 'windows'; + +/// Key for WinUWP platform. +/// +/// Note that UWP is a platform for the purposes of flutter commands like +/// `build` and `run`, but a variant of the `windows` platform for the purposes +/// of plugin pubspecs). +const String kPlatformWinUwp = 'winuwp'; + +/// Key for Win32 variant of the Windows platform. +const String platformVariantWin32 = 'win32'; + +/// Key for UWP variant of the Windows platform. +/// +/// See the note on [kPlatformWinUwp]. +const String platformVariantWinUwp = 'uwp'; + /// Key for enable experiment. const String kEnableExperiment = 'enable-experiment'; diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart index d9c42e220c0b..49da67655e91 100644 --- a/script/tool/lib/src/common/plugin_utils.dart +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -28,8 +28,12 @@ enum PlatformSupport { /// /// If [requiredMode] is provided, the plugin must have the given type of /// implementation in order to return true. -bool pluginSupportsPlatform(String platform, RepositoryPackage package, - {PlatformSupport? requiredMode}) { +bool pluginSupportsPlatform( + String platform, + RepositoryPackage package, { + PlatformSupport? requiredMode, + String? variant, +}) { assert(platform == kPlatformIos || platform == kPlatformAndroid || platform == kPlatformWeb || @@ -65,9 +69,34 @@ bool pluginSupportsPlatform(String platform, RepositoryPackage package, } // If the platform entry is present, then it supports the platform. Check // for required mode if specified. - final bool federated = platformEntry.containsKey('default_package'); - return requiredMode == null || - federated == (requiredMode == PlatformSupport.federated); + if (requiredMode != null) { + final bool federated = platformEntry.containsKey('default_package'); + if (federated != (requiredMode == PlatformSupport.federated)) { + return false; + } + } + + // If a variant is specified, check for that variant. + if (variant != null) { + const String variantsKey = 'supportedVariants'; + if (platformEntry.containsKey(variantsKey)) { + if (!(platformEntry['supportedVariants']! as YamlList) + .contains(variant)) { + return false; + } + } else { + // Platforms with variants have a default variant when unspecified for + // backward compatibility. Must match the flutter tool logic. + const Map defaultVariants = { + kPlatformWindows: platformVariantWin32, + }; + if (variant != defaultVariants[platform]) { + return false; + } + } + } + + return true; } on FileSystemException { return false; } on YamlException { diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 3605dcce1f22..b3434b0659f3 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -36,7 +36,10 @@ class DriveExamplesCommand extends PackageLoopingCommand { argParser.addFlag(kPlatformWeb, help: 'Runs the web implementation of the examples'); argParser.addFlag(kPlatformWindows, - help: 'Runs the Windows implementation of the examples'); + help: 'Runs the Windows (Win32) implementation of the examples'); + argParser.addFlag(kPlatformWinUwp, + help: + 'Runs the UWP implementation of the examples [currently a no-op]'); argParser.addOption( kEnableExperiment, defaultsTo: '', @@ -67,6 +70,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { kPlatformMacos, kPlatformWeb, kPlatformWindows, + kPlatformWinUwp, ]; final int platformCount = platformSwitches .where((String platform) => getBoolArg(platform)) @@ -81,6 +85,10 @@ class DriveExamplesCommand extends PackageLoopingCommand { throw ToolExit(_exitNoPlatformFlags); } + if (getBoolArg(kPlatformWinUwp)) { + logWarning('Driving UWP applications is not yet supported'); + } + String? androidDevice; if (getBoolArg(kPlatformAndroid)) { final List devices = await _getDevicesForPlatform('android'); @@ -116,6 +124,10 @@ class DriveExamplesCommand extends PackageLoopingCommand { ], if (getBoolArg(kPlatformWindows)) kPlatformWindows: ['-d', 'windows'], + // TODO(stuartmorgan): Check these flags once drive supports UWP: + // https://github.com/flutter/flutter/issues/82821 + if (getBoolArg(kPlatformWinUwp)) + kPlatformWinUwp: ['-d', 'winuwp'], }; } @@ -132,7 +144,17 @@ class DriveExamplesCommand extends PackageLoopingCommand { final List deviceFlags = []; for (final MapEntry> entry in _targetDeviceFlags.entries) { - if (pluginSupportsPlatform(entry.key, package)) { + final String platform = entry.key; + String? variant; + if (platform == kPlatformWindows) { + variant = platformVariantWin32; + } else if (platform == kPlatformWinUwp) { + variant = platformVariantWinUwp; + // TODO(stuartmorgan): Remove this once drive supports UWP. + // https://github.com/flutter/flutter/issues/82821 + return PackageResult.skip('Drive does not yet support UWP'); + } + if (pluginSupportsPlatform(platform, package, variant: variant)) { deviceFlags.addAll(entry.value); } else { print('Skipping unsupported platform ${entry.key}...'); diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 9c7291c31ddb..a17107c18e27 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -56,8 +56,8 @@ void main() { test('fails if building fails', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }); processRunner @@ -106,8 +106,8 @@ void main() { test('building for iOS', () async { mockPlatform.isMacOS = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -163,8 +163,8 @@ void main() { test('building for Linux', () async { mockPlatform.isLinux = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformLinux: PlatformSupport.inline, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -212,8 +212,8 @@ void main() { test('building for macOS', () async { mockPlatform.isMacOS = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -258,8 +258,8 @@ void main() { test('building for web', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -284,7 +284,7 @@ void main() { }); test( - 'building for Windows when plugin is not set up for Windows results in no-op', + 'building for win32 when plugin is not set up for Windows results in no-op', () async { mockPlatform.isWindows = true; createFakePlugin('plugin', packagesDir); @@ -296,7 +296,7 @@ void main() { output, containsAllInOrder([ contains('Running for plugin'), - contains('Windows is not supported by this plugin'), + contains('Win32 is not supported by this plugin'), ]), ); @@ -305,11 +305,11 @@ void main() { expect(processRunner.recordedCalls, orderedEquals([])); }); - test('building for Windows', () async { + test('building for win32', () async { mockPlatform.isWindows = true; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformWindows: PlatformSupport.inline + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -321,7 +321,7 @@ void main() { expect( output, containsAllInOrder([ - '\nBUILDING plugin/example for Windows', + '\nBUILDING plugin/example for Win32 (windows)', ]), ); @@ -335,6 +335,91 @@ void main() { ])); }); + test('building for UWP when plugin does not support UWP is a no-op', + () async { + createFakePlugin('plugin', packagesDir); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--winuwp']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('UWP is not supported by this plugin'), + ]), + ); + + print(processRunner.recordedCalls); + // Output should be empty since running build-examples --macos with no macos + // implementation is a no-op. + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('building for UWP', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.federated, + variants: [platformVariantWinUwp]), + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--winuwp']); + + expect( + output, + containsAllInOrder([ + contains('BUILDING plugin/example for UWP (winuwp)'), + ]), + ); + + print(processRunner.recordedCalls); + expect( + processRunner.recordedCalls, + containsAll([ + ProcessCall(getFlutterCommand(mockPlatform), + const ['build', 'winuwp'], pluginExampleDirectory.path), + ])); + }); + + test('building for UWP creates a folder if necessary', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/test', + ], platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.federated, + variants: [platformVariantWinUwp]), + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--winuwp']); + + expect( + output, + contains('Creating temporary winuwp folder'), + ); + + print(processRunner.recordedCalls); + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + getFlutterCommand(mockPlatform), + const ['create', '--platforms=winuwp', '.'], + pluginExampleDirectory.path), + ProcessCall(getFlutterCommand(mockPlatform), + const ['build', 'winuwp'], pluginExampleDirectory.path), + ])); + }); + test( 'building for Android when plugin is not set up for Android results in no-op', () async { @@ -358,8 +443,8 @@ void main() { test('building for Android', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -387,8 +472,8 @@ void main() { test('enable-experiment flag for Android', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -409,8 +494,8 @@ void main() { test('enable-experiment flag for ios', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart index 7f1ba2add00a..2e08f725eb4b 100644 --- a/script/tool/test/common/plugin_utils_test.dart +++ b/script/tool/test/common/plugin_utils_test.dart @@ -36,13 +36,13 @@ void main() { test('all platforms', () async { final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - kPlatformLinux: PlatformSupport.inline, - kPlatformMacos: PlatformSupport.inline, - kPlatformWeb: PlatformSupport.inline, - kPlatformWindows: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), })); expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isTrue); @@ -55,14 +55,12 @@ void main() { test('some platforms', () async { final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( - 'plugin', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformLinux: PlatformSupport.inline, - kPlatformWeb: PlatformSupport.inline, - }, - )); + 'plugin', packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), + })); expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isTrue); expect(pluginSupportsPlatform(kPlatformIos, plugin), isFalse); @@ -74,17 +72,15 @@ void main() { test('inline plugins are only detected as inline', () async { final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( - 'plugin', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - kPlatformLinux: PlatformSupport.inline, - kPlatformMacos: PlatformSupport.inline, - kPlatformWeb: PlatformSupport.inline, - kPlatformWindows: PlatformSupport.inline, - }, - )); + 'plugin', packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + })); expect( pluginSupportsPlatform(kPlatformAndroid, plugin, @@ -137,19 +133,16 @@ void main() { }); test('federated plugins are only detected as federated', () async { - const String pluginName = 'plugin'; final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( - pluginName, - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.federated, - kPlatformIos: PlatformSupport.federated, - kPlatformLinux: PlatformSupport.federated, - kPlatformMacos: PlatformSupport.federated, - kPlatformWeb: PlatformSupport.federated, - kPlatformWindows: PlatformSupport.federated, - }, - )); + 'plugin', packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.federated), + kPlatformIos: const PlatformDetails(PlatformSupport.federated), + kPlatformLinux: const PlatformDetails(PlatformSupport.federated), + kPlatformMacos: const PlatformDetails(PlatformSupport.federated), + kPlatformWeb: const PlatformDetails(PlatformSupport.federated), + kPlatformWindows: const PlatformDetails(PlatformSupport.federated), + })); expect( pluginSupportsPlatform(kPlatformAndroid, plugin, @@ -200,5 +193,84 @@ void main() { requiredMode: PlatformSupport.inline), isFalse); }); + + test('windows without variants is only win32', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }, + )); + + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWin32), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWinUwp), + isFalse); + }); + + test('windows with both variants matches win32 and winuwp', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformWindows: const PlatformDetails( + PlatformSupport.federated, + variants: [platformVariantWin32, platformVariantWinUwp], + ), + })); + + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWin32), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWinUwp), + isTrue); + }); + + test('win32 plugin is only win32', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformWindows: const PlatformDetails( + PlatformSupport.federated, + variants: [platformVariantWin32], + ), + })); + + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWin32), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWinUwp), + isFalse); + }); + + test('winup plugin is only winuwp', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.federated, + variants: [platformVariantWinUwp]), + }, + )); + + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWin32), + isFalse); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + variant: platformVariantWinUwp), + isTrue); + }); }); } diff --git a/script/tool/test/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index 4439d13c3625..0066cc53f61a 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -21,7 +21,7 @@ void main() { setUp(() { // Since the core of this command is a call to 'flutter create', the test // has to use the real filesystem. Put everything possible in a unique - // temporary to minimize affect on the host system. + // temporary to minimize effect on the host system. fileSystem = const LocalFileSystem(); testRoot = fileSystem.systemTempDirectory.createTempSync(); packagesDir = testRoot.childDirectory('packages'); diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index bbf865d3edf2..85d2326d0689 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -127,8 +127,8 @@ void main() { 'example/test_driver/integration_test.dart', 'example/integration_test/foo_test.dart', ], - platformSupport: { - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -192,9 +192,9 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -242,9 +242,9 @@ void main() { extraFiles: [ 'example/test_driver/plugin_test.dart', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -275,9 +275,9 @@ void main() { extraFiles: [ 'example/lib/main.dart', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -311,9 +311,9 @@ void main() { 'example/integration_test/foo_test.dart', 'example/integration_test/ignore_me.dart', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -397,8 +397,8 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformLinux: PlatformSupport.inline, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), }, ); @@ -470,8 +470,8 @@ void main() { 'example/test_driver/plugin.dart', 'example/macos/macos.swift', ], - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -541,8 +541,8 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); @@ -615,8 +615,8 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformWindows: PlatformSupport.inline + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), }, ); @@ -654,6 +654,40 @@ void main() { ])); }); + test('driving UWP is a no-op', () async { + createFakePlugin( + 'plugin', + packagesDir, + extraFiles: [ + 'example/test_driver/plugin_test.dart', + 'example/test_driver/plugin.dart', + ], + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline, + variants: [platformVariantWinUwp]), + }, + ); + + final List output = await runCapturingPrint(runner, [ + 'drive-examples', + '--winuwp', + ]); + + expect( + output, + containsAllInOrder([ + contains('Driving UWP applications is not yet supported'), + contains('Running for plugin'), + contains('SKIPPING: Drive does not yet support UWP'), + contains('No issues found!'), + ]), + ); + + // Output should be empty since running drive-examples --windows on a + // non-Windows plugin is a no-op. + expect(processRunner.recordedCalls, []); + }); + test('driving on an Android plugin', () async { final Directory pluginDirectory = createFakePlugin( 'plugin', @@ -662,8 +696,8 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), }, ); @@ -712,8 +746,8 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -745,8 +779,8 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -800,9 +834,9 @@ void main() { 'example/test_driver/plugin_test.dart', 'example/test_driver/plugin.dart', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -842,8 +876,8 @@ void main() { 'plugin', packagesDir, examples: [], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); @@ -874,8 +908,8 @@ void main() { 'example/integration_test/bar_test.dart', 'example/integration_test/foo_test.dart', ], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); @@ -906,8 +940,8 @@ void main() { extraFiles: [ 'example/test_driver/integration_test.dart', ], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); @@ -942,8 +976,8 @@ void main() { 'example/integration_test/bar_test.dart', 'example/integration_test/foo_test.dart', ], - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }, ); diff --git a/script/tool/test/lint_android_command_test.dart b/script/tool/test/lint_android_command_test.dart index d08058468636..5670a64f30d8 100644 --- a/script/tool/test/lint_android_command_test.dart +++ b/script/tool/test/lint_android_command_test.dart @@ -43,8 +43,8 @@ void main() { final Directory pluginDir = createFakePlugin('plugin1', packagesDir, extraFiles: [ 'example/android/gradlew', - ], platformSupport: { - kPlatformAndroid: PlatformSupport.inline + ], platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }); final Directory androidDir = @@ -74,8 +74,8 @@ void main() { test('fails if gradlew is missing', () async { createFakePlugin('plugin1', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }); Error? commandError; @@ -96,8 +96,8 @@ void main() { test('fails if linting finds issues', () async { createFakePlugin('plugin1', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }); processRunner.mockProcessesForExecutable['gradlew'] = [ @@ -138,8 +138,8 @@ void main() { test('skips non-inline plugins', () async { createFakePlugin('plugin1', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.federated + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.federated) }); final List output = diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index f367dc80182f..7b2a3d3ba39c 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -115,8 +115,8 @@ void main() { test('reports skips with no tests', () async { final Directory pluginDirectory1 = createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -154,8 +154,8 @@ void main() { group('iOS', () { test('skip if iOS is not supported', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final List output = await runCapturingPrint(runner, @@ -171,8 +171,8 @@ void main() { test('skip if iOS is implemented in a federated package', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.federated + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.federated) }); final List output = await runCapturingPrint(runner, @@ -188,8 +188,8 @@ void main() { test('running with correct destination', () async { final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline + 'plugin', packagesDir, platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) }); final Directory pluginExampleDirectory = @@ -234,8 +234,8 @@ void main() { test('Not specifying --ios-destination assigns an available simulator', () async { final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline + 'plugin', packagesDir, platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) }); final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); @@ -298,8 +298,8 @@ void main() { test('skip if macOS is implemented in a federated package', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.federated, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.federated), }); final List output = @@ -317,8 +317,8 @@ void main() { test('runs for macOS plugin', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -360,8 +360,8 @@ void main() { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/gradlew', @@ -390,8 +390,8 @@ void main() { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/gradlew', @@ -420,8 +420,8 @@ void main() { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/gradlew', @@ -455,8 +455,8 @@ void main() { createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/gradlew', @@ -480,8 +480,8 @@ void main() { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'android/src/test/example_test.java', @@ -519,8 +519,8 @@ void main() { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'android/src/test/example_test.java', @@ -554,8 +554,8 @@ void main() { final Directory plugin = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'android/src/test/example_test.java', @@ -586,8 +586,8 @@ void main() { createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/app/src/test/example_test.java', @@ -618,8 +618,8 @@ void main() { createFakePlugin( 'plugin1', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/gradlew', @@ -630,8 +630,8 @@ void main() { createFakePlugin( 'plugin2', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'android/src/test/example_test.java', @@ -657,8 +657,8 @@ void main() { final Directory pluginDir = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, extraFiles: [ 'example/android/gradlew', @@ -716,8 +716,8 @@ void main() { createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) }, ); @@ -739,8 +739,8 @@ void main() { group('iOS/macOS', () { test('fails if xcrun fails', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); processRunner.mockProcessesForExecutable['xcrun'] = [ @@ -767,8 +767,8 @@ void main() { test('honors unit-only', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -832,8 +832,8 @@ void main() { test('honors integration-only', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -897,8 +897,8 @@ void main() { test('skips when the requested target is not present', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -950,8 +950,8 @@ void main() { test('fails if unable to check for requested target', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -1007,10 +1007,10 @@ void main() { 'example/android/gradlew', 'android/src/test/example_test.java', ], - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -1077,8 +1077,8 @@ void main() { test('runs only macOS for a macOS plugin', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -1121,8 +1121,8 @@ void main() { test('runs only iOS for a iOS plugin', () async { final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline + 'plugin', packagesDir, platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) }); final Directory pluginExampleDirectory = @@ -1193,9 +1193,9 @@ void main() { final Directory pluginDir = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, extraFiles: [ 'example/android/gradlew', @@ -1244,9 +1244,9 @@ void main() { final Directory pluginDir = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformIos: PlatformSupport.inline, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }, extraFiles: [ 'example/android/gradlew', diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 3b350f7d88ae..f8aca38d3478 100644 --- a/script/tool/test/test_command_test.dart +++ b/script/tool/test/test_command_test.dart @@ -180,8 +180,8 @@ void main() { 'plugin', packagesDir, extraFiles: ['test/empty_test.dart'], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 7bd94fb66e22..9b92a5d94ac8 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -41,6 +41,21 @@ Directory createPackagesDirectory( return packagesDir; } +/// Details for platform support in a plugin. +@immutable +class PlatformDetails { + const PlatformDetails( + this.type, { + this.variants = const [], + }); + + /// The type of support for the platform. + final PlatformSupport type; + + /// Any 'supportVariants' to list in the pubspec. + final List variants; +} + /// Creates a plugin package with the given [name] in [packagesDirectory]. /// /// [platformSupport] is a map of platform string to the support details for @@ -54,8 +69,8 @@ Directory createFakePlugin( Directory parentDirectory, { List examples = const ['example'], List extraFiles = const [], - Map platformSupport = - const {}, + Map platformSupport = + const {}, String? version = '0.0.1', }) { final Directory pluginDirectory = createFakePackage(name, parentDirectory, @@ -143,8 +158,8 @@ void createFakePubspec( String name = 'fake_package', bool isFlutter = true, bool isPlugin = false, - Map platformSupport = - const {}, + Map platformSupport = + const {}, String publishTo = 'http://no_pub_server.com', String? version, }) { @@ -160,12 +175,11 @@ flutter: plugin: platforms: '''; - for (final MapEntry platform + for (final MapEntry platform in platformSupport.entries) { yaml += _pluginPlatformSection(platform.key, platform.value, name); } } - yaml += ''' dependencies: flutter: @@ -186,50 +200,73 @@ publish_to: $publishTo # Hardcoded safeguard to prevent this from somehow being } String _pluginPlatformSection( - String platform, PlatformSupport type, String packageName) { - if (type == PlatformSupport.federated) { - return ''' + String platform, PlatformDetails support, String packageName) { + String entry = ''; + // Build the main plugin entry. + if (support.type == PlatformSupport.federated) { + entry = ''' $platform: default_package: ${packageName}_$platform '''; - } - switch (platform) { - case kPlatformAndroid: - return ''' + } else { + switch (platform) { + case kPlatformAndroid: + entry = ''' android: package: io.flutter.plugins.fake pluginClass: FakePlugin '''; - case kPlatformIos: - return ''' + break; + case kPlatformIos: + entry = ''' ios: pluginClass: FLTFakePlugin '''; - case kPlatformLinux: - return ''' + break; + case kPlatformLinux: + entry = ''' linux: pluginClass: FakePlugin '''; - case kPlatformMacos: - return ''' + break; + case kPlatformMacos: + entry = ''' macos: pluginClass: FakePlugin '''; - case kPlatformWeb: - return ''' + break; + case kPlatformWeb: + entry = ''' web: pluginClass: FakePlugin fileName: ${packageName}_web.dart '''; - case kPlatformWindows: - return ''' + break; + case kPlatformWindows: + entry = ''' windows: pluginClass: FakePlugin '''; - default: - assert(false); - return ''; + break; + default: + assert(false, 'Unrecognized platform: $platform'); + break; + } } + + // Add any variants. + if (support.variants.isNotEmpty) { + entry += ''' + supportedVariants: +'''; + for (final String variant in support.variants) { + entry += ''' + - $variant +'''; + } + } + + return entry; } typedef _ErrorHandler = void Function(Error error); diff --git a/script/tool/test/xcode_analyze_command_test.dart b/script/tool/test/xcode_analyze_command_test.dart index 790a526a8ae0..10008ae33a11 100644 --- a/script/tool/test/xcode_analyze_command_test.dart +++ b/script/tool/test/xcode_analyze_command_test.dart @@ -57,8 +57,8 @@ void main() { group('iOS', () { test('skip if iOS is not supported', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final List output = @@ -70,8 +70,8 @@ void main() { test('skip if iOS is implemented in a federated package', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.federated + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.federated) }); final List output = @@ -83,8 +83,8 @@ void main() { test('runs for iOS plugin', () async { final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline + 'plugin', packagesDir, platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) }); final Directory pluginExampleDirectory = @@ -126,8 +126,8 @@ void main() { test('fails if xcrun fails', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) }); processRunner.mockProcessesForExecutable['xcrun'] = [ @@ -172,8 +172,8 @@ void main() { test('skip if macOS is implemented in a federated package', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.federated, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.federated), }); final List output = await runCapturingPrint( @@ -186,8 +186,8 @@ void main() { test('runs for macOS plugin', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -223,8 +223,8 @@ void main() { test('fails if xcrun fails', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); processRunner.mockProcessesForExecutable['xcrun'] = [ @@ -253,9 +253,9 @@ void main() { test('runs both iOS and macOS when supported', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline, - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -313,8 +313,8 @@ void main() { test('runs only macOS for a macOS plugin', () async { final Directory pluginDirectory1 = createFakePlugin( 'plugin', packagesDir, - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }); final Directory pluginExampleDirectory = @@ -354,8 +354,8 @@ void main() { test('runs only iOS for a iOS plugin', () async { final Directory pluginDirectory = createFakePlugin( - 'plugin', packagesDir, platformSupport: { - kPlatformIos: PlatformSupport.inline + 'plugin', packagesDir, platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) }); final Directory pluginExampleDirectory = From 8e8954731570a8088c7398884908c67b38b35a8d Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 26 Aug 2021 15:56:59 -0400 Subject: [PATCH 225/364] Disable some flaky tests (#4274) These tests are failing frequently, and interfering with the tree staying open. Tracking issues: https://github.com/flutter/flutter/issues/88837 https://github.com/flutter/flutter/issues/86915 https://github.com/flutter/flutter/issues/86757 --- .../plugins/androidalarmmanager/BackgroundExecutionTest.java | 2 ++ .../example/integration_test/video_player_test.dart | 5 ++++- .../example/integration_test/webview_flutter_test.dart | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/BackgroundExecutionTest.java b/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/BackgroundExecutionTest.java index d6927232fb80..a841a239d3af 100644 --- a/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/BackgroundExecutionTest.java +++ b/packages/android_alarm_manager/example/android/app/src/androidTest/java/io/plugins/androidalarmmanager/BackgroundExecutionTest.java @@ -17,6 +17,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.rule.ActivityTestRule; import org.junit.Before; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -39,6 +40,7 @@ public void setUp() throws Exception { ActivityScenario.launch(DriverExtensionActivity.class); } + @Ignore("Disabled due to flake: https://github.com/flutter/flutter/issues/88837") @Test public void startBackgroundIsolate() throws Exception { diff --git a/packages/video_player/video_player/example/integration_test/video_player_test.dart b/packages/video_player/video_player/example/integration_test/video_player_test.dart index 6821b26e0409..373538ad365e 100644 --- a/packages/video_player/video_player/example/integration_test/video_player_test.dart +++ b/packages/video_player/video_player/example/integration_test/video_player_test.dart @@ -220,6 +220,9 @@ void main() { await tester.pumpAndSettle(); expect(_controller.value.isPlaying, true); - }, skip: kIsWeb); // Web does not support local assets. + }, + skip: kIsWeb || // Web does not support local assets. + // Extremely flaky on iOS: https://github.com/flutter/flutter/issues/86915 + defaultTargetPlatform == TargetPlatform.iOS); }); } diff --git a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index f3eeee156421..0e128caa8f32 100644 --- a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -1330,7 +1330,9 @@ void main() { await pageLoaded.future; final String? currentUrl = await controller.currentUrl(); expect(currentUrl, 'https://flutter.dev/'); - }); + }, + // Flaky on Android: https://github.com/flutter/flutter/issues/86757 + skip: Platform.isAndroid); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets( From 46dd6093793aa19af45244cd7fc16cc5b0ee2082 Mon Sep 17 00:00:00 2001 From: Majid Hajian Date: Thu, 26 Aug 2021 23:21:07 +0200 Subject: [PATCH 226/364] [camera] Replace device info with new device_info_plus (#4265) --- packages/camera/camera/CHANGELOG.md | 4 ++++ packages/camera/camera/README.md | 2 +- packages/camera/camera/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 73cce2c539c1..6d38fa204540 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.1+1 + +* Replace `device_info` reference with `device_info_plus` in the [README.md](README.md) + ## 0.9.1 * Added `lensAperture`, `sensorExposureTime` and `sensorSensitivity` properties to the `CameraImage` dto. diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index fb6144face9b..c66ed67af6cb 100644 --- a/packages/camera/camera/README.md +++ b/packages/camera/camera/README.md @@ -19,7 +19,7 @@ First, add `camera` as a [dependency in your pubspec.yaml file](https://flutter. ### iOS -iOS 10.0 of higher is needed to use the camera plugin. If compiling for any version lower than 10.0 make sure to check the iOS version before using the camera plugin. For example, using the [device_info](https://pub.dev/packages/device_info) plugin. +iOS 10.0 of higher is needed to use the camera plugin. If compiling for any version lower than 10.0 make sure to check the iOS version before using the camera plugin. For example, using the [device_info_plus](https://pub.dev/packages/device_info_plus) plugin. Add two rows to the `ios/Runner/Info.plist`: diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 08d1e3eead4f..1009191e771e 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.1 +version: 0.9.1+1 environment: sdk: ">=2.12.0 <3.0.0" From 1f502e8b6f0082a0f2e2e89e223b7f2de899177e Mon Sep 17 00:00:00 2001 From: BeMacized Date: Fri, 27 Aug 2021 10:26:04 +0200 Subject: [PATCH 227/364] [camera] Add Android & iOS implementations for pausing the camera preview (#4258) --- packages/camera/camera/CHANGELOG.md | 4 + .../io/flutter/plugins/camera/Camera.java | 78 ++++++---- .../plugins/camera/MethodCallHandlerImpl.java | 16 ++ .../io/flutter/plugins/camera/CameraTest.java | 27 ++++ .../camera/MethodCallHandlerImplTest.java | 69 +++++++++ .../plugins/camera/utils/TestUtils.java | 11 ++ .../ios/Runner.xcodeproj/project.pbxproj | 4 + .../ios/RunnerTests/CameraPreviewPauseTests.m | 50 +++++++ packages/camera/camera/example/lib/main.dart | 28 +++- .../camera/camera/ios/Classes/CameraPlugin.m | 19 ++- .../camera/lib/src/camera_controller.dart | 48 +++++- .../camera/camera/lib/src/camera_preview.dart | 3 +- packages/camera/camera/pubspec.yaml | 4 +- .../camera/test/camera_preview_test.dart | 6 + packages/camera/camera/test/camera_test.dart | 140 ++++++++++++++++++ .../camera/camera/test/camera_value_test.dart | 43 +++--- 16 files changed, 498 insertions(+), 52 deletions(-) create mode 100644 packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java create mode 100644 packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 6d38fa204540..bb0048036f58 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.2 + +* Added functions to pause and resume the camera preview. + ## 0.9.1+1 * Replace `device_info` reference with `device_info_plus` in the [README.md](README.md) diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index 43479aca616c..c036c1c7e9d3 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -126,6 +126,8 @@ class Camera private MediaRecorder mediaRecorder; /** True when recording video. */ private boolean recordingVideo; + /** True when the preview is paused. */ + private boolean pausedPreview; private File captureFile; @@ -428,8 +430,10 @@ private void refreshPreviewCaptureSession( } try { - captureSession.setRepeatingRequest( - previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); + if (!pausedPreview) { + captureSession.setRepeatingRequest( + previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); + } if (onSuccessCallback != null) { onSuccessCallback.run(); @@ -834,33 +838,36 @@ public void setFocusMode(final Result result, @NonNull FocusMode newMode) { * For focus mode an extra step of actually locking/unlocking the * focus has to be done, in order to ensure it goes into the correct state. */ - switch (newMode) { - case locked: - // Perform a single focus trigger. - lockAutoFocus(); - if (captureSession == null) { - Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); - return; - } - - // Set AF state to idle again. - previewRequestBuilder.set( - CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); - - try { - captureSession.setRepeatingRequest( - previewRequestBuilder.build(), null, backgroundHandler); - } catch (CameraAccessException e) { - if (result != null) { - result.error("setFocusModeFailed", "Error setting focus mode: " + e.getMessage(), null); + if (!pausedPreview) { + switch (newMode) { + case locked: + // Perform a single focus trigger. + if (captureSession == null) { + Log.i(TAG, "[unlockAutoFocus] captureSession null, returning"); + return; + } + lockAutoFocus(); + + // Set AF state to idle again. + previewRequestBuilder.set( + CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE); + + try { + captureSession.setRepeatingRequest( + previewRequestBuilder.build(), null, backgroundHandler); + } catch (CameraAccessException e) { + if (result != null) { + result.error( + "setFocusModeFailed", "Error setting focus mode: " + e.getMessage(), null); + } + return; } - return; - } - break; - case auto: - // Cancel current AF trigger and set AF to idle again. - unlockAutoFocus(); - break; + break; + case auto: + // Cancel current AF trigger and set AF to idle again. + unlockAutoFocus(); + break; + } } if (result != null) { @@ -966,6 +973,19 @@ public void unlockCaptureOrientation() { cameraFeatures.getSensorOrientation().unlockCaptureOrientation(); } + /** Pause the preview from dart. */ + public void pausePreview() throws CameraAccessException { + this.pausedPreview = true; + this.captureSession.stopRepeating(); + } + + /** Resume the preview from dart. */ + public void resumePreview() { + this.pausedPreview = false; + this.refreshPreviewCaptureSession( + null, (code, message) -> dartMessenger.sendCameraErrorEvent(message)); + } + public void startPreview() throws CameraAccessException { if (pictureImageReader == null || pictureImageReader.getSurface() == null) return; Log.i(TAG, "startPreview"); @@ -1022,8 +1042,8 @@ public void onError(String errorCode, String errorMessage) { private void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) { imageStreamReader.setOnImageAvailableListener( reader -> { - // Use acquireNextImage since image reader is only for one image. Image img = reader.acquireNextImage(); + // Use acquireNextImage since image reader is only for one image. if (img == null) return; List> planes = new ArrayList<>(); diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index 893785f1a58f..5e25353cbca9 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -339,6 +339,22 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) } break; } + case "pausePreview": + { + try { + camera.pausePreview(); + result.success(null); + } catch (Exception e) { + handleException(e, result); + } + break; + } + case "resumePreview": + { + camera.resumePreview(); + result.success(null); + break; + } case "dispose": { if (camera != null) { diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java index cab2ae8974a4..5431df0df636 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -744,6 +744,33 @@ public void unlockCaptureOrientation_shouldUnlockCaptureOrientation() { verify(mockSensorOrientationFeature, times(1)).unlockCaptureOrientation(); } + @Test + public void pausePreview_shouldPausePreview() throws CameraAccessException { + camera.pausePreview(); + + assertEquals(TestUtils.getPrivateField(camera, "pausedPreview"), true); + verify(mockCaptureSession, times(1)).stopRepeating(); + } + + @Test + public void resumePreview_shouldResumePreview() throws CameraAccessException { + camera.resumePreview(); + + assertEquals(TestUtils.getPrivateField(camera, "pausedPreview"), false); + verify(mockCaptureSession, times(1)).setRepeatingRequest(any(), any(), any()); + } + + @Test + public void resumePreview_shouldSendErrorEventOnCameraAccessException() + throws CameraAccessException { + when(mockCaptureSession.setRepeatingRequest(any(), any(), any())) + .thenThrow(new CameraAccessException(0)); + + camera.resumePreview(); + + verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); + } + private static class TestCameraFeatureFactory implements CameraFeatureFactory { private final AutoFocusFeature mockAutoFocusFeature; private final ExposureLockFeature mockExposureLockFeature; diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java new file mode 100644 index 000000000000..35eed7a66a1a --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java @@ -0,0 +1,69 @@ +// Copyright 2013 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. + +package io.flutter.plugins.camera; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.app.Activity; +import android.hardware.camera2.CameraAccessException; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.camera.utils.TestUtils; +import io.flutter.view.TextureRegistry; +import org.junit.Before; +import org.junit.Test; + +public class MethodCallHandlerImplTest { + + MethodChannel.MethodCallHandler handler; + MethodChannel.Result mockResult; + Camera mockCamera; + + @Before + public void setUp() { + handler = + new MethodCallHandlerImpl( + mock(Activity.class), + mock(BinaryMessenger.class), + mock(CameraPermissions.class), + mock(CameraPermissions.PermissionsRegistry.class), + mock(TextureRegistry.class), + null); + mockResult = mock(MethodChannel.Result.class); + mockCamera = mock(Camera.class); + TestUtils.setPrivateField(handler, "camera", mockCamera); + } + + @Test + public void onMethodCall_pausePreview_shouldPausePreviewAndSendSuccessResult() + throws CameraAccessException { + handler.onMethodCall(new MethodCall("pausePreview", null), mockResult); + + verify(mockCamera, times(1)).pausePreview(); + verify(mockResult, times(1)).success(null); + } + + @Test + public void onMethodCall_pausePreview_shouldSendErrorResultOnCameraAccessException() + throws CameraAccessException { + doThrow(new CameraAccessException(0)).when(mockCamera).pausePreview(); + + handler.onMethodCall(new MethodCall("pausePreview", null), mockResult); + + verify(mockResult, times(1)).error("CameraAccess", null, null); + } + + @Test + public void onMethodCall_resumePreview_shouldResumePreviewAndSendSuccessResult() { + handler.onMethodCall(new MethodCall("resumePreview", null), mockResult); + + verify(mockCamera, times(1)).resumePreview(); + verify(mockResult, times(1)).success(null); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java index dbf9d11be8b6..fce99b54384b 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/utils/TestUtils.java @@ -33,4 +33,15 @@ public static void setPrivateField(T instance, String fieldName, Object newV Assert.fail("Unable to mock private field: " + fieldName); } } + + public static Object getPrivateField(T instance, String fieldName) { + try { + Field field = instance.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(instance); + } catch (Exception e) { + Assert.fail("Unable to mock private field: " + fieldName); + return null; + } + } } diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index aead167a5e99..5a622f17fc63 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A513685080F868CF2695CE75 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */; }; D065CD815D405ECB22FB1BBA /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */; }; + E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -68,6 +69,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPreviewPauseTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -96,6 +98,7 @@ 03BB766A2665316900CE5A93 /* CameraFocusTests.m */, 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */, 03BB766C2665316900CE5A93 /* Info.plist */, + E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */, ); path = RunnerTests; sourceTree = ""; @@ -359,6 +362,7 @@ buildActionMask = 2147483647; files = ( 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, + E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */, 334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m new file mode 100644 index 000000000000..549b40a52e46 --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m @@ -0,0 +1,50 @@ +// Copyright 2013 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 camera; +@import XCTest; +@import AVFoundation; +#import + +@interface FLTCam : NSObject +@property(assign, nonatomic) BOOL isPreviewPaused; +- (void)pausePreviewWithResult:(FlutterResult)result; +- (void)resumePreviewWithResult:(FlutterResult)result; +@end + +@interface CameraPreviewPauseTests : XCTestCase +@property(readonly, nonatomic) FLTCam* camera; +@end + +@implementation CameraPreviewPauseTests + +- (void)setUp { + _camera = [[FLTCam alloc] init]; +} + +- (void)testPausePreviewWithResult_shouldPausePreview { + XCTestExpectation* resultExpectation = + [self expectationWithDescription:@"Succeeding result with nil value"]; + [_camera pausePreviewWithResult:^void(id _Nullable result) { + XCTAssertNil(result); + [resultExpectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + XCTAssertTrue(_camera.isPreviewPaused); +} + +- (void)testResumePreviewWithResult_shouldResumePreview { + XCTestExpectation* resultExpectation = + [self expectationWithDescription:@"Succeeding result with nil value"]; + [_camera resumePreviewWithResult:^void(id _Nullable result) { + XCTAssertNil(result); + [resultExpectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + XCTAssertFalse(_camera.isPreviewPaused); +} + +@end diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 2314aecbece3..364f59d81356 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -530,7 +530,16 @@ class _CameraExampleHomeState extends State cameraController.value.isRecordingVideo ? onStopButtonPressed : null, - ) + ), + IconButton( + icon: const Icon(Icons.pause_presentation), + color: + cameraController != null && cameraController.value.isPreviewPaused + ? Colors.red + : Colors.blue, + onPressed: + cameraController == null ? null : onPausePreviewButtonPressed, + ), ], ); } @@ -747,6 +756,23 @@ class _CameraExampleHomeState extends State }); } + Future onPausePreviewButtonPressed() async { + final CameraController? cameraController = controller; + + if (cameraController == null || !cameraController.value.isInitialized) { + showInSnackBar('Error: select a camera first.'); + return; + } + + if (cameraController.value.isPreviewPaused) { + await cameraController.resumePreview(); + } else { + await cameraController.pausePreview(); + } + + if (mounted) setState(() {}); + } + void onPauseButtonPressed() { pauseVideoRecording().then((_) { if (mounted) setState(() {}); diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index ea03ce57649c..cb93e9f5349d 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -330,6 +330,7 @@ @interface FLTCam : NSObject isRecordingVideo && _isRecordingPaused; @@ -150,6 +159,8 @@ class CameraValue { DeviceOrientation? deviceOrientation, Optional? lockedCaptureOrientation, Optional? recordingOrientation, + bool? isPreviewPaused, + Optional? previewPauseOrientation, }) { return CameraValue( isInitialized: isInitialized ?? this.isInitialized, @@ -172,6 +183,10 @@ class CameraValue { recordingOrientation: recordingOrientation == null ? this.recordingOrientation : recordingOrientation.orNull, + isPreviewPaused: isPreviewPaused ?? this.isPreviewPaused, + previewPauseOrientation: previewPauseOrientation == null + ? this.previewPauseOrientation + : previewPauseOrientation.orNull, ); } @@ -190,7 +205,9 @@ class CameraValue { 'focusPointSupported: $focusPointSupported, ' 'deviceOrientation: $deviceOrientation, ' 'lockedCaptureOrientation: $lockedCaptureOrientation, ' - 'recordingOrientation: $recordingOrientation)'; + 'recordingOrientation: $recordingOrientation, ' + 'isPreviewPaused: $isPreviewPaused, ' + 'previewPausedOrientation: $previewPauseOrientation)'; } } @@ -325,6 +342,35 @@ class CameraController extends ValueNotifier { await CameraPlatform.instance.prepareForVideoRecording(); } + /// Pauses the current camera preview + Future pausePreview() async { + if (value.isPreviewPaused) { + return; + } + try { + await CameraPlatform.instance.pausePreview(_cameraId); + value = value.copyWith( + isPreviewPaused: true, + previewPauseOrientation: Optional.of(this.value.deviceOrientation)); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Resumes the current camera preview + Future resumePreview() async { + if (!value.isPreviewPaused) { + return; + } + try { + await CameraPlatform.instance.resumePreview(_cameraId); + value = value.copyWith( + isPreviewPaused: false, previewPauseOrientation: Optional.absent()); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + /// Captures an image and returns the file where it was saved. /// /// Throws a [CameraException] if the capture fails. diff --git a/packages/camera/camera/lib/src/camera_preview.dart b/packages/camera/camera/lib/src/camera_preview.dart index 1df9f8e2e393..6a15896bfa47 100644 --- a/packages/camera/camera/lib/src/camera_preview.dart +++ b/packages/camera/camera/lib/src/camera_preview.dart @@ -71,7 +71,8 @@ class CameraPreview extends StatelessWidget { DeviceOrientation _getApplicableOrientation() { return controller.value.isRecordingVideo ? controller.value.recordingOrientation! - : (controller.value.lockedCaptureOrientation ?? + : (controller.value.previewPauseOrientation ?? + controller.value.lockedCaptureOrientation ?? controller.value.deviceOrientation); } } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 1009191e771e..3e3fad15051b 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.1+1 +version: 0.9.2 environment: sdk: ">=2.12.0 <3.0.0" @@ -20,7 +20,7 @@ flutter: pluginClass: CameraPlugin dependencies: - camera_platform_interface: ^2.0.0 + camera_platform_interface: ^2.1.0 flutter: sdk: flutter pedantic: ^1.10.0 diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index 8275461192b4..14afddaea070 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -113,6 +113,12 @@ class FakeController extends ValueNotifier @override Future unlockCaptureOrientation() async {} + + @override + Future pausePreview() async {} + + @override + Future resumePreview() async {} } void main() { diff --git a/packages/camera/camera/test/camera_test.dart b/packages/camera/camera/test/camera_test.dart index 26382a9b7d60..6904e68ef89f 100644 --- a/packages/camera/camera/test/camera_test.dart +++ b/packages/camera/camera/test/camera_test.dart @@ -1137,6 +1137,138 @@ void main() { .called(4); }); + test('pausePreview() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + cameraController.value = cameraController.value + .copyWith(deviceOrientation: DeviceOrientation.portraitUp); + + await cameraController.pausePreview(); + + verify(CameraPlatform.instance.pausePreview(cameraController.cameraId)) + .called(1); + expect(cameraController.value.isPreviewPaused, equals(true)); + expect(cameraController.value.previewPauseOrientation, + DeviceOrientation.portraitUp); + }); + + test('pausePreview() does not call $CameraPlatform when already paused', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + cameraController.value = + cameraController.value.copyWith(isPreviewPaused: true); + + await cameraController.pausePreview(); + + verifyNever( + CameraPlatform.instance.pausePreview(cameraController.cameraId)); + expect(cameraController.value.isPreviewPaused, equals(true)); + }); + + test('pausePreview() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + when(CameraPlatform.instance.pausePreview(cameraController.cameraId)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.pausePreview(), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + + test('resumePreview() calls $CameraPlatform', () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + cameraController.value = + cameraController.value.copyWith(isPreviewPaused: true); + + await cameraController.resumePreview(); + + verify(CameraPlatform.instance.resumePreview(cameraController.cameraId)) + .called(1); + expect(cameraController.value.isPreviewPaused, equals(false)); + }); + + test('resumePreview() does not call $CameraPlatform when not paused', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + cameraController.value = + cameraController.value.copyWith(isPreviewPaused: false); + + await cameraController.resumePreview(); + + verifyNever( + CameraPlatform.instance.resumePreview(cameraController.cameraId)); + expect(cameraController.value.isPreviewPaused, equals(false)); + }); + + test('resumePreview() throws $CameraException on $PlatformException', + () async { + CameraController cameraController = CameraController( + CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + await cameraController.initialize(); + cameraController.value = + cameraController.value.copyWith(isPreviewPaused: true); + when(CameraPlatform.instance.resumePreview(cameraController.cameraId)) + .thenThrow( + PlatformException( + code: 'TEST_ERROR', + message: 'This is a test error message', + details: null, + ), + ); + + expect( + cameraController.resumePreview(), + throwsA(isA().having( + (error) => error.description, + 'TEST_ERROR', + 'This is a test error message', + ))); + }); + test('lockCaptureOrientation() calls $CameraPlatform', () async { CameraController cameraController = CameraController( CameraDescription( @@ -1314,6 +1446,14 @@ class MockCameraPlatform extends Mock Future unlockCaptureOrientation(int? cameraId) async => super .noSuchMethod(Invocation.method(#unlockCaptureOrientation, [cameraId])); + @override + Future pausePreview(int? cameraId) async => + super.noSuchMethod(Invocation.method(#pausePreview, [cameraId])); + + @override + Future resumePreview(int? cameraId) async => + super.noSuchMethod(Invocation.method(#resumePreview, [cameraId])); + @override Future getMaxZoomLevel(int? cameraId) async => super.noSuchMethod( Invocation.method(#getMaxZoomLevel, [cameraId]), diff --git a/packages/camera/camera/test/camera_value_test.dart b/packages/camera/camera/test/camera_value_test.dart index e0378cca2cb9..4718d8943c34 100644 --- a/packages/camera/camera/test/camera_value_test.dart +++ b/packages/camera/camera/test/camera_value_test.dart @@ -29,6 +29,8 @@ void main() { lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp, focusPointSupported: true, + isPreviewPaused: false, + previewPauseOrientation: DeviceOrientation.portraitUp, ); expect(cameraValue, isA()); @@ -46,6 +48,8 @@ void main() { expect( cameraValue.lockedCaptureOrientation, DeviceOrientation.portraitUp); expect(cameraValue.recordingOrientation, DeviceOrientation.portraitUp); + expect(cameraValue.isPreviewPaused, false); + expect(cameraValue.previewPauseOrientation, DeviceOrientation.portraitUp); }); test('Can be created as uninitialized', () { @@ -66,6 +70,8 @@ void main() { expect(cameraValue.deviceOrientation, DeviceOrientation.portraitUp); expect(cameraValue.lockedCaptureOrientation, null); expect(cameraValue.recordingOrientation, null); + expect(cameraValue.isPreviewPaused, isFalse); + expect(cameraValue.previewPauseOrientation, null); }); test('Can be copied with isInitialized', () { @@ -87,6 +93,8 @@ void main() { expect(cameraValue.deviceOrientation, DeviceOrientation.portraitUp); expect(cameraValue.lockedCaptureOrientation, null); expect(cameraValue.recordingOrientation, null); + expect(cameraValue.isPreviewPaused, isFalse); + expect(cameraValue.previewPauseOrientation, null); }); test('Has aspectRatio after setting size', () { @@ -117,25 +125,26 @@ void main() { test('toString() works as expected', () { var cameraValue = const CameraValue( - isInitialized: false, - errorDescription: null, - previewSize: Size(10, 10), - isRecordingPaused: false, - isRecordingVideo: false, - isTakingPicture: false, - isStreamingImages: false, - flashMode: FlashMode.auto, - exposureMode: ExposureMode.auto, - focusMode: FocusMode.auto, - exposurePointSupported: true, - focusPointSupported: true, - deviceOrientation: DeviceOrientation.portraitUp, - lockedCaptureOrientation: DeviceOrientation.portraitUp, - recordingOrientation: DeviceOrientation.portraitUp, - ); + isInitialized: false, + errorDescription: null, + previewSize: Size(10, 10), + isRecordingPaused: false, + isRecordingVideo: false, + isTakingPicture: false, + isStreamingImages: false, + flashMode: FlashMode.auto, + exposureMode: ExposureMode.auto, + focusMode: FocusMode.auto, + exposurePointSupported: true, + focusPointSupported: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: DeviceOrientation.portraitUp, + recordingOrientation: DeviceOrientation.portraitUp, + isPreviewPaused: true, + previewPauseOrientation: DeviceOrientation.portraitUp); expect(cameraValue.toString(), - 'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: ExposureMode.auto, focusMode: FocusMode.auto, exposurePointSupported: true, focusPointSupported: true, deviceOrientation: DeviceOrientation.portraitUp, lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp)'); + 'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: ExposureMode.auto, focusMode: FocusMode.auto, exposurePointSupported: true, focusPointSupported: true, deviceOrientation: DeviceOrientation.portraitUp, lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp, isPreviewPaused: true, previewPausedOrientation: DeviceOrientation.portraitUp)'); }); }); } From 3f808c1dd1abd00a8256b16a4f8cef6e8010f37d Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Fri, 27 Aug 2021 14:44:39 +0200 Subject: [PATCH 228/364] [in_app_purchase] Ensure purchases correctly report if they are acknowledged on Android (#4257) * Ensure purchases correctly show they are acknowledged * Update packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md Co-authored-by: Rene Floor * Update packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md Co-authored-by: Rene Floor * Modify so public API is not changed Co-authored-by: Rene Floor --- .../in_app_purchase_android/CHANGELOG.md | 6 ++++- .../types/google_play_purchase_details.dart | 25 ++++++------------- .../in_app_purchase_android/pubspec.yaml | 2 +- .../purchase_wrapper_test.dart | 22 +++++++++++++++- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 8e342a65422c..1a03ba27feb7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.4+6 + +* Ensure that purchases correctly indicate whether they are acknowledged or not. The `PurchaseDetails.pendingCompletePurchase` field now correctly indicates if the purchase still needs to be completed. + ## 0.1.4+5 * Add `implements` to pubspec. @@ -9,7 +13,7 @@ ## 0.1.4+3 -- Updated installation instructions in README. +* Updated installation instructions in README. ## 0.1.4+2 diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart index 66e3a8f5a590..53b58bd664fd 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart @@ -20,30 +20,19 @@ class GooglePlayPurchaseDetails extends PurchaseDetails { required this.billingClientPurchase, required PurchaseStatus status, }) : super( - productID: productID, - purchaseID: purchaseID, - transactionDate: transactionDate, - verificationData: verificationData, - status: status) { - this.status = status; + productID: productID, + purchaseID: purchaseID, + transactionDate: transactionDate, + verificationData: verificationData, + status: status, + ) { + this.pendingCompletePurchase = !billingClientPurchase.isAcknowledged; } /// Points back to the [PurchaseWrapper] which was used to generate this /// [GooglePlayPurchaseDetails] object. final PurchaseWrapper billingClientPurchase; - late PurchaseStatus _status; - - /// The status that this [PurchaseDetails] is currently on. - PurchaseStatus get status => _status; - set status(PurchaseStatus status) { - _pendingCompletePurchase = status == PurchaseStatus.purchased; - _status = status; - } - - bool _pendingCompletePurchase = false; - bool get pendingCompletePurchase => _pendingCompletePurchase; - /// Generate a [PurchaseDetails] object based on an Android [Purchase] object. factory GooglePlayPurchaseDetails.fromPurchase(PurchaseWrapper purchase) { final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails( diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 745b651e5828..d9b09827824b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+5 +version: 0.1.4+6 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart index bb7ff8535c7a..70b9fcad4da7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -71,9 +71,10 @@ void main() { expect(parsed, equals(expected)); }); - test('toPurchaseDetails() should return correct PurchaseDetail object', () { + test('fromPurchase() should return correct PurchaseDetail object', () { final GooglePlayPurchaseDetails details = GooglePlayPurchaseDetails.fromPurchase(dummyPurchase); + expect(details.purchaseID, dummyPurchase.orderId); expect(details.productID, dummyPurchase.sku); expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); @@ -84,6 +85,25 @@ void main() { expect(details.verificationData.serverVerificationData, dummyPurchase.purchaseToken); expect(details.billingClientPurchase, dummyPurchase); + expect(details.pendingCompletePurchase, false); + }); + + test( + 'fromPurchase() should return set pendingCompletePurchase to true for unacknowledged purchase', + () { + final GooglePlayPurchaseDetails details = + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + + expect(details.purchaseID, dummyPurchase.orderId); + expect(details.productID, dummyPurchase.sku); + expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); + expect(details.verificationData, isNotNull); + expect(details.verificationData.source, kIAPSource); + expect(details.verificationData.localVerificationData, + dummyPurchase.originalJson); + expect(details.verificationData.serverVerificationData, + dummyPurchase.purchaseToken); + expect(details.billingClientPurchase, dummyUnacknowledgedPurchase); expect(details.pendingCompletePurchase, true); }); }); From 0588bfea1d5ce41bf84adcd798d234758149850d Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 27 Aug 2021 11:36:03 -0400 Subject: [PATCH 229/364] Remove support for bypassing, or prompting for, git tagging (#4275) We never want a plugin to be published without tagging the release, so there's no reason to support the added complexity of these flags. Similarly, once someone has confirmed publishing, we don't want to give them an opt-out for doing the tag. --- script/tool/CHANGELOG.md | 2 + .../tool/lib/src/publish_plugin_command.dart | 69 +++----- .../test/publish_plugin_command_test.dart | 150 ++++-------------- script/tool/test/util.dart | 3 +- 4 files changed, 51 insertions(+), 173 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index a32fb0016cb3..b10237b45913 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -5,6 +5,8 @@ - Pubspec validation now checks for `implements` in implementation packages. - Pubspec valitation now checks the full relative path of `repository` entries. - `build-examples` now supports UWP plugins via a `--winuwp` flag. +- **Breaking change**: `publish` no longer accepts `--no-tag-release` or + `--no-push-flags`. Releases now always tag and push. ## 0.5.0 diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index be9e6d300125..8432e342cda3 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -66,23 +66,9 @@ class PublishPluginCommand extends PluginCommand { argParser.addMultiOption(_pubFlagsOption, help: 'A list of options that will be forwarded on to pub. Separate multiple flags with commas.'); - argParser.addFlag( - _tagReleaseOption, - help: 'Whether or not to tag the release.', - defaultsTo: true, - negatable: true, - ); - argParser.addFlag( - _pushTagsOption, - help: - 'Whether or not tags should be pushed to a remote after creation. Ignored if tag-release is false.', - defaultsTo: true, - negatable: true, - ); argParser.addOption( _remoteOption, - help: - 'The name of the remote to push the tags to. Ignored if push-tags or tag-release is false.', + help: 'The name of the remote to push the tags to.', // Flutter convention is to use "upstream" for the single source of truth, and "origin" for personal forks. defaultsTo: 'upstream', ); @@ -104,15 +90,12 @@ class PublishPluginCommand extends PluginCommand { ); argParser.addFlag(_skipConfirmationFlag, help: 'Run the command without asking for Y/N inputs.\n' - 'This command will add a `--force` flag to the `pub publish` command if it is not added with $_pubFlagsOption\n' - 'It also skips the y/n inputs when pushing tags to remote.\n', + 'This command will add a `--force` flag to the `pub publish` command if it is not added with $_pubFlagsOption\n', defaultsTo: false, negatable: true); } static const String _packageOption = 'package'; - static const String _tagReleaseOption = 'tag-release'; - static const String _pushTagsOption = 'push-tags'; static const String _pubFlagsOption = 'pub-publish-flags'; static const String _remoteOption = 'remote'; static const String _allChangedFlag = 'all-changed'; @@ -150,19 +133,14 @@ class PublishPluginCommand extends PluginCommand { print('Checking local repo...'); final GitDir repository = await gitDir; - - final bool shouldPushTag = getBoolArg(_pushTagsOption); - _RemoteInfo? remote; - if (shouldPushTag) { - final String remoteName = getStringArg(_remoteOption); - final String? remoteUrl = await _verifyRemote(remoteName); - if (remoteUrl == null) { - printError( - 'Unable to find URL for remote $remoteName; cannot push tags'); - throw ToolExit(1); - } - remote = _RemoteInfo(name: remoteName, url: remoteUrl); + final String remoteName = getStringArg(_remoteOption); + final String? remoteUrl = await _verifyRemote(remoteName); + if (remoteUrl == null) { + printError('Unable to find URL for remote $remoteName; cannot push tags'); + throw ToolExit(1); } + final _RemoteInfo remote = _RemoteInfo(name: remoteName, url: remoteUrl); + print('Local repo is ready!'); if (getBoolArg(_dryRunFlag)) { print('=============== DRY RUN ==============='); @@ -187,7 +165,7 @@ class PublishPluginCommand extends PluginCommand { Future _publishAllChangedPackages({ required GitDir baseGitDir, - _RemoteInfo? remoteForTagPush, + required _RemoteInfo remoteForTagPush, }) async { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); final List changedPubspecs = @@ -249,24 +227,21 @@ class PublishPluginCommand extends PluginCommand { return packagesFailed.isEmpty; } - // Publish the package to pub with `pub publish`. - // If `_tagReleaseOption` is on, git tag the release. - // If `remoteForTagPush` is non-null, the tag will be pushed to that remote. + // Publish the package to pub with `pub publish`, then git tag the release + // and push the tag to [remoteForTagPush]. // Returns `true` if publishing and tagging are successful. Future _publishAndTagPackage({ required Directory packageDir, - _RemoteInfo? remoteForTagPush, + required _RemoteInfo remoteForTagPush, }) async { if (!await _publishPlugin(packageDir: packageDir)) { return false; } - if (getBoolArg(_tagReleaseOption)) { - if (!await _tagRelease( - packageDir: packageDir, - remoteForPush: remoteForTagPush, - )) { - return false; - } + if (!await _tagRelease( + packageDir: packageDir, + remoteForPush: remoteForTagPush, + )) { + return false; } print('Released [${packageDir.basename}] successfully.'); return true; @@ -479,14 +454,6 @@ Safe to ignore if the package is deleted in this commit. required _RemoteInfo remote, }) async { assert(remote != null && tag != null); - if (!getBoolArg(_skipConfirmationFlag)) { - print('Ready to push $tag to ${remote.url} (y/n)?'); - final String? input = _stdin.readLineSync(); - if (input?.toLowerCase() != 'y') { - print('Tag push canceled.'); - return false; - } - } if (!getBoolArg(_dryRunFlag)) { final io.ProcessResult result = await (await gitDir).runCommand( ['push', remote.name, tag], diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 663c2633a9db..927c146a874d 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -80,8 +80,8 @@ void main() { test('requires an existing flag', () async { Error? commandError; - final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--package', 'iamerror', '--no-push-tags'], + final List output = await runCapturingPrint( + commandRunner, ['publish-plugin', '--package', 'iamerror'], errorHandler: (Error e) { commandError = e; }); @@ -100,8 +100,8 @@ void main() { ]; Error? commandError; - final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--package', 'foo', '--no-push-tags'], + final List output = await runCapturingPrint( + commandRunner, ['publish-plugin', '--package', 'foo'], errorHandler: (Error e) { commandError = e; }); @@ -141,56 +141,6 @@ void main() { 'Unable to find URL for remote upstream; cannot push tags'), ])); }); - - test("doesn't validate the remote if it's not pushing tags", () async { - createFakePlugin('foo', packagesDir, examples: []); - - // Checking the remote should fail. - processRunner.mockProcessesForExecutable['git-remote'] = [ - MockProcess(exitCode: 1), - ]; - - final List output = await runCapturingPrint( - commandRunner, [ - 'publish-plugin', - '--package', - 'foo', - '--no-push-tags', - '--no-tag-release' - ]); - - expect( - output, - containsAllInOrder([ - contains('Running `pub publish ` in /packages/foo...'), - contains('Package published!'), - contains('Released [foo] successfully.'), - ])); - }); - - test('can publish non-flutter package', () async { - const String packageName = 'a_package'; - createFakePackage(packageName, packagesDir); - - final List output = await runCapturingPrint( - commandRunner, [ - 'publish-plugin', - '--package', - packageName, - '--no-push-tags', - '--no-tag-release' - ]); - - expect( - output, - containsAllInOrder( - [ - contains('Running `pub publish ` in /packages/a_package...'), - contains('Package published!'), - ], - ), - ); - }); }); group('Publishes package', () { @@ -206,13 +156,7 @@ void main() { ]; final List output = await runCapturingPrint( - commandRunner, [ - 'publish-plugin', - '--package', - 'foo', - '--no-push-tags', - '--no-tag-release' - ]); + commandRunner, ['publish-plugin', '--package', 'foo']); expect( output, @@ -227,13 +171,8 @@ void main() { mockStdin.mockUserInputs.add(utf8.encode('user input')); - await runCapturingPrint(commandRunner, [ - 'publish-plugin', - '--package', - 'foo', - '--no-push-tags', - '--no-tag-release' - ]); + await runCapturingPrint( + commandRunner, ['publish-plugin', '--package', 'foo']); expect(processRunner.mockPublishProcess.stdinMock.lines, contains('user input')); @@ -247,8 +186,6 @@ void main() { 'publish-plugin', '--package', 'foo', - '--no-push-tags', - '--no-tag-release', '--pub-publish-flags', '--dry-run,--server=foo' ]); @@ -272,8 +209,6 @@ void main() { 'publish-plugin', '--package', 'foo', - '--no-push-tags', - '--no-tag-release', '--skip-confirmation', '--pub-publish-flags', '--server=foo' @@ -300,8 +235,6 @@ void main() { 'publish-plugin', '--package', 'foo', - '--no-push-tags', - '--no-tag-release', ], errorHandler: (Error e) { commandError = e; }); @@ -324,8 +257,6 @@ void main() { '--package', 'foo', '--dry-run', - '--no-push-tags', - '--no-tag-release', ]); expect( @@ -340,6 +271,28 @@ void main() { 'Done!' ])); }); + + test('can publish non-flutter package', () async { + const String packageName = 'a_package'; + createFakePackage(packageName, packagesDir); + + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--package', + packageName, + ]); + + expect( + output, + containsAllInOrder( + [ + contains('Running `pub publish ` in /packages/a_package...'), + contains('Package published!'), + ], + ), + ); + }); }); group('Tags release', () { @@ -349,7 +302,6 @@ void main() { 'publish-plugin', '--package', 'foo', - '--no-push-tags', ]); expect(processRunner.recordedCalls, @@ -369,7 +321,6 @@ void main() { 'publish-plugin', '--package', 'foo', - '--no-push-tags', ], errorHandler: (Error e) { commandError = e; }); @@ -388,25 +339,6 @@ void main() { }); group('Pushes tags', () { - test('requires user confirmation', () async { - createFakePlugin('foo', packagesDir, examples: []); - - mockStdin.readLineOutput = 'help'; - - Error? commandError; - final List output = - await runCapturingPrint(commandRunner, [ - 'publish-plugin', - '--package', - 'foo', - ], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - expect(output, contains('Tag push canceled.')); - }); - test('to upstream by default', () async { createFakePlugin('foo', packagesDir, examples: []); @@ -502,30 +434,6 @@ void main() { contains('Released [foo] successfully.'), ])); }); - - test('only if tagging and pushing to remotes are both enabled', () async { - createFakePlugin('foo', packagesDir, examples: []); - - final List output = - await runCapturingPrint(commandRunner, [ - 'publish-plugin', - '--package', - 'foo', - '--no-tag-release', - ]); - - expect( - processRunner.recordedCalls - .map((ProcessCall call) => call.executable), - isNot(contains('git-push'))); - expect( - output, - containsAllInOrder([ - contains('Running `pub publish ` in /packages/foo...'), - contains('Package published!'), - contains('Released [foo] successfully.'), - ])); - }); }); group('Auto release (all-changed flag)', () { diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 9b92a5d94ac8..74c036489233 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -107,7 +107,8 @@ Directory createFakePackage( final Directory packageDirectory = parentDirectory.childDirectory(name); packageDirectory.createSync(recursive: true); - createFakePubspec(packageDirectory, name: name, isFlutter: isFlutter); + createFakePubspec(packageDirectory, + name: name, isFlutter: isFlutter, version: version); createFakeCHANGELOG(packageDirectory, ''' ## $version * Some changes. From 797c61d6b613068326997ac2456dff70af41a3ec Mon Sep 17 00:00:00 2001 From: BeMacized Date: Fri, 27 Aug 2021 17:41:03 +0200 Subject: [PATCH 230/364] [camera] Fix a disposed camera controller throwing an exception when being replaced in the preview widget. (#4272) --- packages/camera/camera/CHANGELOG.md | 4 ++++ packages/camera/camera/example/lib/main.dart | 10 +++------- packages/camera/camera/lib/src/camera_controller.dart | 10 ++++++++++ packages/camera/camera/pubspec.yaml | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index bb0048036f58..5a3a1bf251d7 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.2+1 + +* Fixed camera controller throwing an exception when being replaced in the preview widget. + ## 0.9.2 * Added functions to pause and resume the camera preview. diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 364f59d81356..a8067001aae5 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -603,7 +603,9 @@ class _CameraExampleHomeState extends State } void onNewCameraSelected(CameraDescription cameraDescription) async { - final previousCameraController = controller; + if (controller != null) { + await controller!.dispose(); + } final CameraController cameraController = CameraController( cameraDescription, @@ -614,10 +616,6 @@ class _CameraExampleHomeState extends State controller = cameraController; - if (mounted) { - setState(() {}); - } - // If the controller is updated then update the UI. cameraController.addListener(() { if (mounted) setState(() {}); @@ -650,8 +648,6 @@ class _CameraExampleHomeState extends State if (mounted) { setState(() {}); } - - await previousCameraController?.dispose(); } void onTakePictureButtonPressed() { diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index 58193bd204c4..f21a3b12c81f 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -824,4 +824,14 @@ class CameraController extends ValueNotifier { ); } } + + @override + void removeListener(VoidCallback listener) { + // Prevent ValueListenableBuilder in CameraPreview widget from causing an + // exception to be thrown by attempting to remove its own listener after + // the controller has already been disposed. + if (!_isDisposed) { + super.removeListener(listener); + } + } } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 3e3fad15051b..400b8c03f44a 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.2 +version: 0.9.2+1 environment: sdk: ">=2.12.0 <3.0.0" From 78b914d4556d839598ce196efaabb1e4a44d6384 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Fri, 27 Aug 2021 10:26:03 -0700 Subject: [PATCH 231/364] [ci.yaml] Add linux platform properties (#4282) --- .ci.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.ci.yaml b/.ci.yaml index 86bc72c7aebf..1205c1ac104d 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -9,6 +9,17 @@ enabled_branches: - master platform_properties: + linux: + properties: + caches: >- + [ + ] + dependencies: > + [ + {"dependency": "curl"} + ] + device_type: none + os: Linux windows: properties: caches: >- From 83f8c4c6a435ab543537875afbb7d5c2e0f6a4dc Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Fri, 27 Aug 2021 11:22:10 -0700 Subject: [PATCH 232/364] [ci] update wait-on-check version and set verbose to false (#4262) --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6753e5a2add..d3418683fde2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,15 +28,17 @@ jobs: run: dart pub get working-directory: ${{ github.workspace }}/script/tool - # # This workflow should be the last to run. So wait for all the other tests to succeed. + # This workflow should be the last to run. So wait for all the other tests to succeed. - name: Wait on all tests - uses: lewagon/wait-on-check-action@1b1630e169116b58a4b933d5ad7effc46d3d312d + uses: lewagon/wait-on-check-action@0179dfc359f90a703c41240506f998ee1603f9ea with: ref: ${{ github.sha }} running-workflow-name: 'release' repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 180 # seconds allowed-conclusions: success + # verbose:true will produce too many logs that hang github actions web UI. + verbose: false - name: run release run: | From 8d4be08b6f5f50444e8f0415736f9fb97f1adf97 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Fri, 27 Aug 2021 11:50:11 -0700 Subject: [PATCH 233/364] [ci] Fix wrong hash used in release.yml (#4286) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3418683fde2..7f1a4a360949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: # This workflow should be the last to run. So wait for all the other tests to succeed. - name: Wait on all tests - uses: lewagon/wait-on-check-action@0179dfc359f90a703c41240506f998ee1603f9ea + uses: lewagon/wait-on-check-action@a0f99ce1e713de216866868c3da4d4183a051cbe with: ref: ${{ github.sha }} running-workflow-name: 'release' From 4f4a88900b772a83e5f7111a05be9132ec17d65e Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Fri, 27 Aug 2021 12:18:06 -0700 Subject: [PATCH 234/364] [ci] Revert the wait-on-check hash change (#4287) --- .github/workflows/release.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f1a4a360949..00fa140b131a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,15 +30,13 @@ jobs: # This workflow should be the last to run. So wait for all the other tests to succeed. - name: Wait on all tests - uses: lewagon/wait-on-check-action@a0f99ce1e713de216866868c3da4d4183a051cbe + uses: lewagon/wait-on-check-action@1b1630e169116b58a4b933d5ad7effc46d3d312d with: ref: ${{ github.sha }} running-workflow-name: 'release' repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 180 # seconds allowed-conclusions: success - # verbose:true will produce too many logs that hang github actions web UI. - verbose: false - name: run release run: | From 9b614eaa8394de04bbd530bcba8a585b1ded4cab Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Fri, 27 Aug 2021 12:21:05 -0700 Subject: [PATCH 235/364] [ci.yaml] Add roller to presubmit (#4283) --- .ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.ci.yaml b/.ci.yaml index 1205c1ac104d..6b5c385aa98e 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -58,7 +58,6 @@ targets: - name: Linux ci_yaml plugins roller recipe: infra/ci_yaml - bringup: true timeout: 30 scheduler: luci runIf: From d9f90b5d4b92d764c04352a2f5f21a441199f184 Mon Sep 17 00:00:00 2001 From: Konstantin Scheglov Date: Fri, 27 Aug 2021 14:11:03 -0600 Subject: [PATCH 236/364] Fix UNNECESSARY_TYPE_CHECK_TRUE. (#4284) --- .../sku_details_wrapper.dart | 45 +++++++++---------- .../test/fakes/fake_ios_platform.dart | 1 - .../sk_methodchannel_apis_test.dart | 3 -- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index da4d5c73d851..5bbe7504783d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -127,22 +127,21 @@ class SkuDetailsWrapper { return false; } - final SkuDetailsWrapper typedOther = other; - return typedOther is SkuDetailsWrapper && - typedOther.description == description && - typedOther.freeTrialPeriod == freeTrialPeriod && - typedOther.introductoryPrice == introductoryPrice && - typedOther.introductoryPriceMicros == introductoryPriceMicros && - typedOther.introductoryPriceCycles == introductoryPriceCycles && - typedOther.introductoryPricePeriod == introductoryPricePeriod && - typedOther.price == price && - typedOther.priceAmountMicros == priceAmountMicros && - typedOther.sku == sku && - typedOther.subscriptionPeriod == subscriptionPeriod && - typedOther.title == title && - typedOther.type == type && - typedOther.originalPrice == originalPrice && - typedOther.originalPriceAmountMicros == originalPriceAmountMicros; + return other is SkuDetailsWrapper && + other.description == description && + other.freeTrialPeriod == freeTrialPeriod && + other.introductoryPrice == introductoryPrice && + other.introductoryPriceMicros == introductoryPriceMicros && + other.introductoryPriceCycles == introductoryPriceCycles && + other.introductoryPricePeriod == introductoryPricePeriod && + other.price == price && + other.priceAmountMicros == priceAmountMicros && + other.sku == sku && + other.subscriptionPeriod == subscriptionPeriod && + other.title == title && + other.type == type && + other.originalPrice == originalPrice && + other.originalPriceAmountMicros == originalPriceAmountMicros; } @override @@ -195,10 +194,9 @@ class SkuDetailsResponseWrapper { return false; } - final SkuDetailsResponseWrapper typedOther = other; - return typedOther is SkuDetailsResponseWrapper && - typedOther.billingResult == billingResult && - typedOther.skuDetailsList == skuDetailsList; + return other is SkuDetailsResponseWrapper && + other.billingResult == billingResult && + other.skuDetailsList == skuDetailsList; } @override @@ -240,10 +238,9 @@ class BillingResultWrapper { return false; } - final BillingResultWrapper typedOther = other; - return typedOther is BillingResultWrapper && - typedOther.responseCode == responseCode && - typedOther.debugMessage == debugMessage; + return other is BillingResultWrapper && + other.responseCode == responseCode && + other.debugMessage == debugMessage; } @override diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart index 9797dba59684..e7dbd1a49ae2 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart @@ -117,7 +117,6 @@ class FakeIOSPlatform { } List productIDS = List.castFrom(call.arguments); - assert(productIDS is List, 'invalid argument type'); List invalidFound = []; List products = []; for (String productID in productIDS) { diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart index 892b9d346ada..c7f7d800f45f 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_methodchannel_apis_test.dart @@ -219,9 +219,6 @@ class FakeIOSPlatform { switch (call.method) { // request makers case '-[InAppPurchasePlugin startProductRequest:result:]': - List productIDS = - List.castFrom(call.arguments); - assert(productIDS is List, 'invalid argument type'); startProductRequestParam = call.arguments; if (getProductRequestFailTest) { return Future.value(null); From 39c1880eee2de00e458ff9d16b266c987eac9f8f Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 27 Aug 2021 23:01:05 +0200 Subject: [PATCH 237/364] [camera_web] Add an initial device orientation event (#4278) --- .../integration_test/camera_web_test.dart | 38 ++++++++++++++++++- .../camera/camera_web/lib/src/camera_web.dart | 6 ++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 9ab8c511f753..4c1d96983fc7 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -2269,6 +2269,40 @@ void main() { }); }); + testWidgets('emits the initial DeviceOrientationChangedEvent', + (tester) async { + when( + () => cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitPrimary, + ), + ).thenReturn(DeviceOrientation.portraitUp); + + // Set the initial screen orientation to portraitPrimary. + when(() => screenOrientation.type) + .thenReturn(OrientationType.portraitPrimary); + + final eventStreamController = StreamController(); + + when(() => screenOrientation.onChange) + .thenAnswer((_) => eventStreamController.stream); + + final Stream eventStream = + CameraPlatform.instance.onDeviceOrientationChanged(); + + final streamQueue = StreamQueue(eventStream); + + expect( + await streamQueue.next, + equals( + DeviceOrientationChangedEvent( + DeviceOrientation.portraitUp, + ), + ), + ); + + await streamQueue.cancel(); + }); + testWidgets( 'emits a DeviceOrientationChangedEvent ' 'when the screen orientation is changed', (tester) async { @@ -2299,7 +2333,7 @@ void main() { when(() => screenOrientation.type) .thenReturn(OrientationType.landscapePrimary); - eventStreamController.add(Event('orientationChanged')); + eventStreamController.add(Event('change')); expect( await streamQueue.next, @@ -2315,7 +2349,7 @@ void main() { when(() => screenOrientation.type) .thenReturn(OrientationType.portraitSecondary); - eventStreamController.add(Event('orientationChanged')); + eventStreamController.add(Event('change')); expect( await streamQueue.next, diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 19ee43f36660..9d349a788558 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -343,7 +343,11 @@ class CameraPlugin extends CameraPlatform { final orientation = window?.screen?.orientation; if (orientation != null) { - return orientation.onChange.map( + // Create an initial orientation event that emits the device orientation + // as soon as subscribed to this stream. + final initialOrientationEvent = html.Event("change"); + + return orientation.onChange.startWith(initialOrientationEvent).map( (html.Event _) { final deviceOrientation = _cameraService .mapOrientationTypeToDeviceOrientation(orientation.type!); From 863d088f9908e6174aab257dd0790200285ce02d Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 27 Aug 2021 23:05:41 +0200 Subject: [PATCH 238/364] [camera_web] Update ultra high resolution to 4096x2160 (#4279) * feat: update ultra high resolution to 4096x2160 * test: update max and ultra high resolution tests --- .../example/integration_test/camera_service_test.dart | 8 ++++---- packages/camera/camera_web/lib/src/camera_service.dart | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_service_test.dart b/packages/camera/camera_web/example/integration_test/camera_service_test.dart index 937f023f4b36..346ab26237ea 100644 --- a/packages/camera/camera_web/example/integration_test/camera_service_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_service_test.dart @@ -696,20 +696,20 @@ void main() { group('mapResolutionPresetToSize', () { testWidgets( - 'returns 3840x2160 ' + 'returns 4096x2160 ' 'when the resolution preset is max', (tester) async { expect( cameraService.mapResolutionPresetToSize(ResolutionPreset.max), - equals(Size(3840, 2160)), + equals(Size(4096, 2160)), ); }); testWidgets( - 'returns 3840x2160 ' + 'returns 4096x2160 ' 'when the resolution preset is ultraHigh', (tester) async { expect( cameraService.mapResolutionPresetToSize(ResolutionPreset.ultraHigh), - equals(Size(3840, 2160)), + equals(Size(4096, 2160)), ); }); diff --git a/packages/camera/camera_web/lib/src/camera_service.dart b/packages/camera/camera_web/lib/src/camera_service.dart index 612b2b138fdb..5ba5c80395cc 100644 --- a/packages/camera/camera_web/lib/src/camera_service.dart +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -277,7 +277,7 @@ class CameraService { switch (resolutionPreset) { case ResolutionPreset.max: case ResolutionPreset.ultraHigh: - return Size(3840, 2160); + return Size(4096, 2160); case ResolutionPreset.veryHigh: return Size(1920, 1080); case ResolutionPreset.high: From e07f161f8679d4cf20ae44c6e006317f29b39b4b Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Fri, 27 Aug 2021 23:05:52 +0200 Subject: [PATCH 239/364] [camera_web] Mute the camera preview (#4280) * feat: mute the camera preview * test: update camera video element test with muted set to true --- .../camera/camera_web/example/integration_test/camera_test.dart | 2 +- packages/camera/camera_web/lib/src/camera.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index f331cc1485ab..34142d146a56 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -98,7 +98,7 @@ void main() { expect(camera.videoElement, isNotNull); expect(camera.videoElement.autoplay, isFalse); - expect(camera.videoElement.muted, !audioConstraints.enabled); + expect(camera.videoElement.muted, isTrue); expect(camera.videoElement.srcObject, mediaStream); expect(camera.videoElement.attributes.keys, contains('playsinline')); diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 74d8546fbb12..b7f55c6dea2e 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -119,7 +119,7 @@ class Camera { videoElement ..autoplay = false - ..muted = !options.audio.enabled + ..muted = true ..srcObject = stream ..setAttribute('playsinline', ''); From e02b647ea035b855389cdca0b8f4dca48f4dfdc7 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Sat, 28 Aug 2021 12:39:15 -0400 Subject: [PATCH 240/364] [flutter_plugin_tools] Switch 'publish' from --package to --packages (#4285) Replaces the `publish`-command-specific `--package` flag with support for `--packages`, and unifies the flow with the existing looping for `--all-changed`. This better aligns the command's API with the rest of the commands, and reduces divergence in the two flows (e.g., `--package` would attempt to publish and fail if the package was already published, whereas now using `--packages` will use the flow that pre-checks against `pub.dev`). It also sets up a structure that will allow easily converting it to the new base package looping command that most other commands now use, which will be done in a follow-up. Since all calls now attempt to contact `pub.dev`, the tests have been adjusted to always mock the HTTP client so they will be hermetic. Part of https://github.com/flutter/flutter/issues/83413 --- script/tool/CHANGELOG.md | 2 + .../tool/lib/src/publish_plugin_command.dart | 171 +++++---- .../test/publish_plugin_command_test.dart | 334 +++++------------- 3 files changed, 168 insertions(+), 339 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index b10237b45913..634360461c8d 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -7,6 +7,8 @@ - `build-examples` now supports UWP plugins via a `--winuwp` flag. - **Breaking change**: `publish` no longer accepts `--no-tag-release` or `--no-push-flags`. Releases now always tag and push. +- **Breaking change**: `publish`'s `--package` flag has been replaced with the + `--packages` flag used by most other packages. ## 0.5.0 diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 8432e342cda3..aafe7868d8d0 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; @@ -58,11 +59,6 @@ class PublishPluginCommand extends PluginCommand { _stdin = stdinput ?? io.stdin, super(packagesDir, platform: platform, processRunner: processRunner, gitDir: gitDir) { - argParser.addOption( - _packageOption, - help: 'The package to publish.' - 'If the package directory name is different than its pubspec.yaml name, then this should specify the directory.', - ); argParser.addMultiOption(_pubFlagsOption, help: 'A list of options that will be forwarded on to pub. Separate multiple flags with commas.'); @@ -75,8 +71,8 @@ class PublishPluginCommand extends PluginCommand { argParser.addFlag( _allChangedFlag, help: - 'Release all plugins that contains pubspec changes at the current commit compares to the base-sha.\n' - 'The $_packageOption option is ignored if this is on.', + 'Release all packages that contains pubspec changes at the current commit compares to the base-sha.\n' + 'The --packages option is ignored if this is on.', defaultsTo: false, ); argParser.addFlag( @@ -95,7 +91,6 @@ class PublishPluginCommand extends PluginCommand { negatable: true); } - static const String _packageOption = 'package'; static const String _pubFlagsOption = 'pub-publish-flags'; static const String _remoteOption = 'remote'; static const String _allChangedFlag = 'all-changed'; @@ -113,7 +108,7 @@ class PublishPluginCommand extends PluginCommand { @override final String description = - 'Attempts to publish the given plugin and tag its release on GitHub.\n' + 'Attempts to publish the given packages and tag the release(s) on GitHub.\n' 'If running this on CI, an environment variable named $_pubCredentialName must be set to a String that represents the pub credential JSON.\n' 'WARNING: Do not check in the content of pub credential JSON, it should only come from secure sources.'; @@ -123,14 +118,6 @@ class PublishPluginCommand extends PluginCommand { @override Future run() async { - final String packageName = getStringArg(_packageOption); - final bool publishAllChanged = getBoolArg(_allChangedFlag); - if (packageName.isEmpty && !publishAllChanged) { - printError( - 'Must specify a package to publish. See `plugin_tools help publish-plugin`.'); - throw ToolExit(1); - } - print('Checking local repo...'); final GitDir repository = await gitDir; final String remoteName = getStringArg(_remoteOption); @@ -146,36 +133,52 @@ class PublishPluginCommand extends PluginCommand { print('=============== DRY RUN ==============='); } - bool successful; - if (publishAllChanged) { - successful = await _publishAllChangedPackages( - baseGitDir: repository, - remoteForTagPush: remote, - ); - } else { - successful = await _publishAndTagPackage( - packageDir: _getPackageDir(packageName), - remoteForTagPush: remote, - ); - } + final List packages = await _getPackagesToProcess() + .where((PackageEnumerationEntry entry) => !entry.excluded) + .toList(); + bool successful = true; + + successful = await _publishPackages( + packages, + baseGitDir: repository, + remoteForTagPush: remote, + ); - _pubVersionFinder.httpClient.close(); await _finish(successful); } - Future _publishAllChangedPackages({ + Stream _getPackagesToProcess() async* { + if (getBoolArg(_allChangedFlag)) { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final List changedPubspecs = + await gitVersionFinder.getChangedPubSpecs(); + + for (final String pubspecPath in changedPubspecs) { + // Convert git's Posix-style paths to a path that matches the current + // filesystem. + final String localStylePubspecPath = + path.joinAll(p.posix.split(pubspecPath)); + final File pubspecFile = packagesDir.fileSystem + .directory((await gitDir).path) + .childFile(localStylePubspecPath); + yield PackageEnumerationEntry(RepositoryPackage(pubspecFile.parent), + excluded: false); + } + } else { + yield* getTargetPackages(filterExcluded: false); + } + } + + Future _publishPackages( + List packages, { required GitDir baseGitDir, required _RemoteInfo remoteForTagPush, }) async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); - final List changedPubspecs = - await gitVersionFinder.getChangedPubSpecs(); - if (changedPubspecs.isEmpty) { + if (packages.isEmpty) { print('No version updates in this commit.'); return true; } - print('Getting existing tags...'); final io.ProcessResult existingTagsResult = await baseGitDir.runCommand(['tag', '--sort=-committerdate']); final List existingTags = (existingTagsResult.stdout as String) @@ -185,16 +188,11 @@ class PublishPluginCommand extends PluginCommand { final List packagesReleased = []; final List packagesFailed = []; - for (final String pubspecPath in changedPubspecs) { - // Convert git's Posix-style paths to a path that matches the current - // filesystem. - final String localStylePubspecPath = - path.joinAll(p.posix.split(pubspecPath)); - final File pubspecFile = packagesDir.fileSystem - .directory(baseGitDir.path) - .childFile(localStylePubspecPath); + for (final PackageEnumerationEntry entry in packages) { + final RepositoryPackage package = entry.package; + final _CheckNeedsReleaseResult result = await _checkNeedsRelease( - pubspecFile: pubspecFile, + package: package, existingTags: existingTags, ); switch (result) { @@ -203,17 +201,15 @@ class PublishPluginCommand extends PluginCommand { case _CheckNeedsReleaseResult.noRelease: continue; case _CheckNeedsReleaseResult.failure: - packagesFailed.add(pubspecFile.parent.basename); + packagesFailed.add(package.displayName); continue; } print('\n'); - if (await _publishAndTagPackage( - packageDir: pubspecFile.parent, - remoteForTagPush: remoteForTagPush, - )) { - packagesReleased.add(pubspecFile.parent.basename); + if (await _publishAndTagPackage(package, + remoteForTagPush: remoteForTagPush)) { + packagesReleased.add(package.displayName); } else { - packagesFailed.add(pubspecFile.parent.basename); + packagesFailed.add(package.displayName); } print('\n'); } @@ -230,31 +226,32 @@ class PublishPluginCommand extends PluginCommand { // Publish the package to pub with `pub publish`, then git tag the release // and push the tag to [remoteForTagPush]. // Returns `true` if publishing and tagging are successful. - Future _publishAndTagPackage({ - required Directory packageDir, - required _RemoteInfo remoteForTagPush, + Future _publishAndTagPackage( + RepositoryPackage package, { + _RemoteInfo? remoteForTagPush, }) async { - if (!await _publishPlugin(packageDir: packageDir)) { + if (!await _publishPackage(package)) { return false; } if (!await _tagRelease( - packageDir: packageDir, + package, remoteForPush: remoteForTagPush, )) { return false; } - print('Released [${packageDir.basename}] successfully.'); + print('Published ${package.directory.basename} successfully.'); return true; } // Returns a [_CheckNeedsReleaseResult] that indicates the result. Future<_CheckNeedsReleaseResult> _checkNeedsRelease({ - required File pubspecFile, + required RepositoryPackage package, required List existingTags, }) async { + final File pubspecFile = package.pubspecFile; if (!pubspecFile.existsSync()) { print(''' -The file at The pubspec file at ${pubspecFile.path} does not exist. Publishing will not happen for ${pubspecFile.parent.basename}. +The pubspec file at ${pubspecFile.path} does not exist. Publishing will not happen for ${pubspecFile.parent.basename}. Safe to ignore if the package is deleted in this commit. '''); return _CheckNeedsReleaseResult.noRelease; @@ -279,7 +276,8 @@ Safe to ignore if the package is deleted in this commit. return _CheckNeedsReleaseResult.failure; } - // Check if the package named `packageName` with `version` has already published. + // Check if the package named `packageName` with `version` has already + // been published. final Version version = pubspec.version!; final PubVersionFinderResponse pubVersionFinderResponse = await _pubVersionFinder.getPackageVersion(packageName: pubspec.name); @@ -303,15 +301,15 @@ Safe to ignore if the package is deleted in this commit. return _CheckNeedsReleaseResult.release; } - // Publish the plugin. + // Publish the package. // // Returns `true` if successful, `false` otherwise. - Future _publishPlugin({required Directory packageDir}) async { - final bool gitStatusOK = await _checkGitStatus(packageDir); + Future _publishPackage(RepositoryPackage package) async { + final bool gitStatusOK = await _checkGitStatus(package); if (!gitStatusOK) { return false; } - final bool publishOK = await _publish(packageDir); + final bool publishOK = await _publish(package); if (!publishOK) { return false; } @@ -319,15 +317,15 @@ Safe to ignore if the package is deleted in this commit. return true; } - // Tag the release with -v, and, if [remoteForTagPush] + // Tag the release with -v, and, if [remoteForTagPush] // is provided, push it to that remote. // // Return `true` if successful, `false` otherwise. - Future _tagRelease({ - required Directory packageDir, + Future _tagRelease( + RepositoryPackage package, { _RemoteInfo? remoteForPush, }) async { - final String tag = _getTag(packageDir); + final String tag = _getTag(package); print('Tagging release $tag...'); if (!getBoolArg(_dryRunFlag)) { final io.ProcessResult result = await (await gitDir).runCommand( @@ -351,6 +349,7 @@ Safe to ignore if the package is deleted in this commit. } Future _finish(bool successful) async { + _pubVersionFinder.httpClient.close(); await _stdinSubscription?.cancel(); _stdinSubscription = null; if (successful) { @@ -361,20 +360,14 @@ Safe to ignore if the package is deleted in this commit. } } - // Returns the packageDirectory based on the package name. - // Throws ToolExit if the `package` doesn't exist. - Directory _getPackageDir(String packageName) { - final Directory packageDir = packagesDir.childDirectory(packageName); - if (!packageDir.existsSync()) { - printError('${packageDir.absolute.path} does not exist.'); - throw ToolExit(1); - } - return packageDir; - } - - Future _checkGitStatus(Directory packageDir) async { + Future _checkGitStatus(RepositoryPackage package) async { final io.ProcessResult statusResult = await (await gitDir).runCommand( - ['status', '--porcelain', '--ignored', packageDir.absolute.path], + [ + 'status', + '--porcelain', + '--ignored', + package.directory.absolute.path + ], throwOnError: false, ); if (statusResult.exitCode != 0) { @@ -402,10 +395,10 @@ Safe to ignore if the package is deleted in this commit. return getRemoteUrlResult.stdout as String?; } - Future _publish(Directory packageDir) async { + Future _publish(RepositoryPackage package) async { final List publishFlags = getStringListArg(_pubFlagsOption); - print( - 'Running `pub publish ${publishFlags.join(' ')}` in ${packageDir.absolute.path}...\n'); + print('Running `pub publish ${publishFlags.join(' ')}` in ' + '${package.directory.absolute.path}...\n'); if (getBoolArg(_dryRunFlag)) { return true; } @@ -419,7 +412,7 @@ Safe to ignore if the package is deleted in this commit. final io.Process publish = await processRunner.start( flutterCommand, ['pub', 'publish'] + publishFlags, - workingDirectory: packageDir); + workingDirectory: package.directory); publish.stdout.transform(utf8.decoder).listen((String data) => print(data)); publish.stderr.transform(utf8.decoder).listen((String data) => print(data)); _stdinSubscription ??= _stdin @@ -427,14 +420,14 @@ Safe to ignore if the package is deleted in this commit. .listen((String data) => publish.stdin.writeln(data)); final int result = await publish.exitCode; if (result != 0) { - printError('Publish ${packageDir.basename} failed.'); + printError('Publishing ${package.directory.basename} failed.'); return false; } return true; } - String _getTag(Directory packageDir) { - final File pubspecFile = packageDir.childFile('pubspec.yaml'); + String _getTag(RepositoryPackage package) { + final File pubspecFile = package.pubspecFile; final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; final String name = pubspecYaml['name'] as String; diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 927c146a874d..ae3d768fcc70 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -30,6 +30,8 @@ void main() { late CommandRunner commandRunner; late MockStdin mockStdin; late FileSystem fileSystem; + // Map of package name to mock response. + late Map> mockHttpResponses; void _createMockCredentialFile() { final String credentialPath = PublishPluginCommand.getCredentialPath(); @@ -41,8 +43,20 @@ void main() { setUp(() async { fileSystem = MemoryFileSystem(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); - processRunner = TestProcessRunner(); + + mockHttpResponses = >{}; + final MockClient mockClient = MockClient((http.Request request) async { + final String packageName = + request.url.pathSegments.last.replaceAll('.json', ''); + final Map? response = mockHttpResponses[packageName]; + if (response != null) { + return http.Response(json.encode(response), 200); + } + // Default to simulating the plugin never having been published. + return http.Response('', 404); + }); + gitDir = MockGitDir(); when(gitDir.path).thenReturn(packagesDir.parent.path); when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) @@ -58,39 +72,16 @@ void main() { mockStdin = MockStdin(); commandRunner = CommandRunner('tester', '') - ..addCommand(PublishPluginCommand(packagesDir, - processRunner: processRunner, stdinput: mockStdin, gitDir: gitDir)); + ..addCommand(PublishPluginCommand( + packagesDir, + processRunner: processRunner, + stdinput: mockStdin, + gitDir: gitDir, + httpClient: mockClient, + )); }); group('Initial validation', () { - test('requires a package flag', () async { - Error? commandError; - final List output = await runCapturingPrint( - commandRunner, ['publish-plugin'], errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - expect( - output, - containsAllInOrder([ - contains('Must specify a package to publish.'), - ])); - }); - - test('requires an existing flag', () async { - Error? commandError; - final List output = await runCapturingPrint( - commandRunner, ['publish-plugin', '--package', 'iamerror'], - errorHandler: (Error e) { - commandError = e; - }); - - expect(commandError, isA()); - expect(output, - containsAllInOrder([contains('iamerror does not exist')])); - }); - test('refuses to proceed with dirty files', () async { final Directory pluginDir = createFakePlugin('foo', packagesDir, examples: []); @@ -100,9 +91,11 @@ void main() { ]; Error? commandError; - final List output = await runCapturingPrint( - commandRunner, ['publish-plugin', '--package', 'foo'], - errorHandler: (Error e) { + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--packages=foo', + ], errorHandler: (Error e) { commandError = e; }); @@ -128,7 +121,7 @@ void main() { Error? commandError; final List output = await runCapturingPrint( - commandRunner, ['publish-plugin', '--package', 'foo'], + commandRunner, ['publish-plugin', '--packages=foo'], errorHandler: (Error e) { commandError = e; }); @@ -145,24 +138,34 @@ void main() { group('Publishes package', () { test('while showing all output from pub publish to the user', () async { - createFakePlugin('foo', packagesDir, examples: []); + createFakePlugin('plugin1', packagesDir, examples: []); + createFakePlugin('plugin2', packagesDir, examples: []); processRunner.mockProcessesForExecutable[flutterCommand] = [ MockProcess( stdout: 'Foo', stderr: 'Bar', stdoutEncoding: utf8, - stderrEncoding: utf8) // pub publish + stderrEncoding: utf8), // pub publish for plugin1 + MockProcess( + stdout: 'Baz', + stdoutEncoding: utf8, + stderrEncoding: utf8), // pub publish for plugin1 ]; - final List output = await runCapturingPrint( - commandRunner, ['publish-plugin', '--package', 'foo']); + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--packages=plugin1,plugin2']); expect( output, containsAllInOrder([ + contains('Running `pub publish ` in /packages/plugin1...'), contains('Foo'), contains('Bar'), + contains('Package published!'), + contains('Running `pub publish ` in /packages/plugin2...'), + contains('Baz'), + contains('Package published!'), ])); }); @@ -172,7 +175,7 @@ void main() { mockStdin.mockUserInputs.add(utf8.encode('user input')); await runCapturingPrint( - commandRunner, ['publish-plugin', '--package', 'foo']); + commandRunner, ['publish-plugin', '--packages=foo']); expect(processRunner.mockPublishProcess.stdinMock.lines, contains('user input')); @@ -184,17 +187,16 @@ void main() { await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', '--pub-publish-flags', - '--dry-run,--server=foo' + '--dry-run,--server=bar' ]); expect( processRunner.recordedCalls, contains(ProcessCall( flutterCommand, - const ['pub', 'publish', '--dry-run', '--server=foo'], + const ['pub', 'publish', '--dry-run', '--server=bar'], pluginDir.path))); }); @@ -207,18 +209,17 @@ void main() { await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', '--skip-confirmation', '--pub-publish-flags', - '--server=foo' + '--server=bar' ]); expect( processRunner.recordedCalls, contains(ProcessCall( flutterCommand, - const ['pub', 'publish', '--server=foo', '--force'], + const ['pub', 'publish', '--server=bar', '--force'], pluginDir.path))); }); @@ -233,8 +234,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', ], errorHandler: (Error e) { commandError = e; }); @@ -243,7 +243,7 @@ void main() { expect( output, containsAllInOrder([ - contains('Publish foo failed.'), + contains('Publishing foo failed.'), ])); }); @@ -254,8 +254,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', '--dry-run', ]); @@ -279,8 +278,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - packageName, + '--packages=$packageName', ]); expect( @@ -300,8 +298,7 @@ void main() { createFakePlugin('foo', packagesDir, examples: []); await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', ]); expect(processRunner.recordedCalls, @@ -319,8 +316,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', ], errorHandler: (Error e) { commandError = e; }); @@ -329,7 +325,7 @@ void main() { expect( output, containsAllInOrder([ - contains('Publish foo failed.'), + contains('Publishing foo failed.'), ])); expect( processRunner.recordedCalls, @@ -347,8 +343,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', ]); expect( @@ -358,7 +353,7 @@ void main() { expect( output, containsAllInOrder([ - contains('Released [foo] successfully.'), + contains('Published foo successfully.'), ])); }); @@ -371,8 +366,7 @@ void main() { await runCapturingPrint(commandRunner, [ 'publish-plugin', '--skip-confirmation', - '--package', - 'foo', + '--packages=foo', ]); expect( @@ -382,7 +376,7 @@ void main() { expect( output, containsAllInOrder([ - contains('Released [foo] successfully.'), + contains('Published foo successfully.'), ])); }); @@ -393,7 +387,7 @@ void main() { mockStdin.readLineOutput = 'y'; final List output = await runCapturingPrint(commandRunner, - ['publish-plugin', '--package', 'foo', '--dry-run']); + ['publish-plugin', '--packages=foo', '--dry-run']); expect( processRunner.recordedCalls @@ -418,8 +412,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - 'foo', + '--packages=foo', '--remote', 'origin', ]); @@ -431,43 +424,23 @@ void main() { expect( output, containsAllInOrder([ - contains('Released [foo] successfully.'), + contains('Published foo successfully.'), ])); }); }); group('Auto release (all-changed flag)', () { test('can release newly created plugins', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': [], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': [], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -492,7 +465,7 @@ void main() { 'Local repo is ready!', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', + 'Packages released: plugin1, plugin2/plugin2', 'Done!' ])); expect( @@ -507,43 +480,21 @@ void main() { test('can release newly created plugins, while there are existing plugins', () async { - const Map httpResponsePlugin0 = { + mockHttpResponses['plugin0'] = { 'name': 'plugin0', 'versions': ['0.0.1'], }; - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': [], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': [], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin0.json') { - return http.Response(json.encode(httpResponsePlugin0), 200); - } else if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - // The existing plugin. createFakePlugin('plugin0', packagesDir); // Non-federated @@ -575,7 +526,7 @@ void main() { 'Local repo is ready!', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', + 'Packages released: plugin1, plugin2/plugin2', 'Done!' ])); expect( @@ -589,35 +540,16 @@ void main() { }); test('can release newly created plugins, dry run', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': [], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': [], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir); // federated @@ -651,7 +583,7 @@ void main() { 'Running `pub publish ` in ${pluginDir2.path}...\n', 'Tagging release plugin2-v0.0.1...', 'Pushing tag to upstream...', - 'Packages released: plugin1, plugin2', + 'Packages released: plugin1, plugin2/plugin2', 'Done!' ])); expect( @@ -661,36 +593,16 @@ void main() { }); test('version change triggers releases.', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': ['0.0.1'], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': ['0.0.1'], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir, version: '0.0.2'); @@ -716,7 +628,7 @@ void main() { 'Local repo is ready!', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2', + 'Packages released: plugin1, plugin2/plugin2', 'Done!' ])); expect( @@ -732,36 +644,16 @@ void main() { test( 'delete package will not trigger publish but exit the command successfully.', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': ['0.0.1'], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': ['0.0.1'], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir, version: '0.0.2'); @@ -786,7 +678,7 @@ void main() { 'Checking local repo...', 'Local repo is ready!', 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'The file at The pubspec file at ${pluginDir2.childFile('pubspec.yaml').path} does not exist. Publishing will not happen for plugin2.\nSafe to ignore if the package is deleted in this commit.\n', + 'The pubspec file at ${pluginDir2.childFile('pubspec.yaml').path} does not exist. Publishing will not happen for plugin2.\nSafe to ignore if the package is deleted in this commit.\n', 'Packages released: plugin1', 'Done!' ])); @@ -798,36 +690,16 @@ void main() { test('Existing versions do not trigger release, also prints out message.', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': ['0.0.2'], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': ['0.0.2'], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir, version: '0.0.2'); @@ -871,36 +743,16 @@ void main() { test( 'Existing versions do not trigger release, but fail if the tags do not exist.', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', 'versions': ['0.0.2'], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', 'versions': ['0.0.2'], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'plugin1.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } else if (request.url.pathSegments.last == 'plugin2.json') { - return http.Response(json.encode(httpResponsePlugin2), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - // Non-federated final Directory pluginDir1 = createFakePlugin('plugin1', packagesDir, version: '0.0.2'); @@ -970,29 +822,11 @@ void main() { }); test('Do not release flutter_plugin_tools', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'flutter_plugin_tools', 'versions': [], }; - final MockClient mockClient = MockClient((http.Request request) async { - if (request.url.pathSegments.last == 'flutter_plugin_tools.json') { - return http.Response(json.encode(httpResponsePlugin1), 200); - } - return http.Response('', 500); - }); - final PublishPluginCommand command = PublishPluginCommand(packagesDir, - processRunner: processRunner, - stdinput: mockStdin, - httpClient: mockClient, - gitDir: gitDir); - - commandRunner = CommandRunner( - 'publish_check_command', - 'Test for publish-check command.', - ); - commandRunner.addCommand(command); - final Directory flutterPluginTools = createFakePlugin('flutter_plugin_tools', packagesDir); processRunner.mockProcessesForExecutable['git-diff'] = [ From 32ca7761cecbd86098856ac503f8cd83eff878f1 Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Sat, 28 Aug 2021 10:13:38 -0700 Subject: [PATCH 241/364] Revert "[ci] Revert the wait-on-check hash change (#4287)" (#4289) Relands #4262 as the white list has been updated to allow the newer wait-on-check version. --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00fa140b131a..7f1a4a360949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,13 +30,15 @@ jobs: # This workflow should be the last to run. So wait for all the other tests to succeed. - name: Wait on all tests - uses: lewagon/wait-on-check-action@1b1630e169116b58a4b933d5ad7effc46d3d312d + uses: lewagon/wait-on-check-action@a0f99ce1e713de216866868c3da4d4183a051cbe with: ref: ${{ github.sha }} running-workflow-name: 'release' repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 180 # seconds allowed-conclusions: success + # verbose:true will produce too many logs that hang github actions web UI. + verbose: false - name: run release run: | From 18129d67395a18b43b18ec0c22dfd8bafa64a826 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Mon, 30 Aug 2021 20:47:40 +0200 Subject: [PATCH 242/364] [camera_web] Do not flip the video on the back camera (#4281) * feat: do not flip the video on the back camera * test: do not flip the video on the back camera tests * feat: update getVideoSize usage --- .../example/integration_test/camera_test.dart | 112 +++++++++++++++++- .../integration_test/camera_web_test.dart | 12 +- .../camera/camera_web/lib/src/camera.dart | 52 ++++++-- .../camera/camera_web/lib/src/camera_web.dart | 2 +- 4 files changed, 158 insertions(+), 20 deletions(-) diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 34142d146a56..712d8c77ff3e 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -85,11 +85,17 @@ void main() { 'creates a video element ' 'with correct properties', (tester) async { const audioConstraints = AudioConstraints(enabled: true); + final videoConstraints = VideoConstraints( + facingMode: FacingModeConstraint( + CameraType.user, + ), + ); final camera = Camera( textureId: textureId, options: CameraOptions( audio: audioConstraints, + video: videoConstraints, ), cameraService: cameraService, ); @@ -108,6 +114,27 @@ void main() { expect(camera.videoElement.style.width, equals('100%')); expect(camera.videoElement.style.height, equals('100%')); expect(camera.videoElement.style.objectFit, equals('cover')); + }); + + testWidgets( + 'flips the video element horizontally ' + 'for a back camera', (tester) async { + final videoConstraints = VideoConstraints( + facingMode: FacingModeConstraint( + CameraType.environment, + ), + ); + + final camera = Camera( + textureId: textureId, + options: CameraOptions( + video: videoConstraints, + ), + cameraService: cameraService, + ); + + await camera.initialize(); + expect(camera.videoElement.style.transform, equals('scaleX(-1)')); }); @@ -376,7 +403,7 @@ void main() { await camera.initialize(); expect( - await camera.getVideoSize(), + camera.getVideoSize(), equals(videoSize), ); }); @@ -396,7 +423,7 @@ void main() { await camera.initialize(); expect( - await camera.getVideoSize(), + camera.getVideoSize(), equals(Size.zero), ); }); @@ -819,6 +846,87 @@ void main() { }); }); + group('getLensDirection', () { + testWidgets( + 'returns a lens direction ' + 'based on the first video track settings', (tester) async { + final videoElement = MockVideoElement(); + + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + )..videoElement = videoElement; + + final firstVideoTrack = MockMediaStreamTrack(); + + when(() => videoElement.srcObject).thenReturn( + FakeMediaStream([ + firstVideoTrack, + MockMediaStreamTrack(), + ]), + ); + + when(firstVideoTrack.getSettings) + .thenReturn({'facingMode': 'environment'}); + + when(() => cameraService.mapFacingModeToLensDirection('environment')) + .thenReturn(CameraLensDirection.external); + + expect( + camera.getLensDirection(), + equals(CameraLensDirection.external), + ); + }); + + testWidgets( + 'returns null ' + 'if the first video track is missing the facing mode', + (tester) async { + final videoElement = MockVideoElement(); + + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + )..videoElement = videoElement; + + final firstVideoTrack = MockMediaStreamTrack(); + + when(() => videoElement.srcObject).thenReturn( + FakeMediaStream([ + firstVideoTrack, + MockMediaStreamTrack(), + ]), + ); + + when(firstVideoTrack.getSettings).thenReturn({}); + + expect( + camera.getLensDirection(), + isNull, + ); + }); + + testWidgets( + 'returns null ' + 'if the camera is missing video tracks', (tester) async { + // Create a video stream with no video tracks. + final videoElement = VideoElement(); + mediaStream = videoElement.captureStream(); + + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + expect( + camera.getLensDirection(), + isNull, + ); + }); + }); + group('getViewType', () { testWidgets('returns a correct view type', (tester) async { final camera = Camera( diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 4c1d96983fc7..4bc10badab05 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -574,9 +574,7 @@ void main() { abortStreamController = StreamController(); endedStreamController = StreamController(); - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); + when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); when(camera.play).thenAnswer((_) => Future.value()); @@ -1660,9 +1658,7 @@ void main() { abortStreamController = StreamController(); endedStreamController = StreamController(); - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); + when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); when(camera.play).thenAnswer((_) => Future.value()); when(camera.dispose).thenAnswer((_) => Future.value()); @@ -1818,9 +1814,7 @@ void main() { abortStreamController = StreamController(); endedStreamController = StreamController(); - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); + when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); when(camera.play).thenAnswer((_) => Future.value()); diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index b7f55c6dea2e..4b7a185b90f7 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -106,7 +106,6 @@ class Camera { ); videoElement = html.VideoElement(); - _applyDefaultVideoStyles(videoElement); divElement = html.DivElement() ..style.setProperty('object-fit', 'cover') @@ -123,6 +122,8 @@ class Camera { ..srcObject = stream ..setAttribute('playsinline', ''); + _applyDefaultVideoStyles(videoElement); + final videoTracks = stream!.getVideoTracks(); if (videoTracks.isNotEmpty) { @@ -149,7 +150,7 @@ class Camera { } /// Pauses the camera stream on the current frame. - void pause() async { + void pause() { videoElement.pause(); } @@ -185,11 +186,17 @@ class Camera { final videoWidth = videoElement.videoWidth; final videoHeight = videoElement.videoHeight; final canvas = html.CanvasElement(width: videoWidth, height: videoHeight); + final isBackCamera = getLensDirection() == CameraLensDirection.back; + + // Flip the picture horizontally if it is not taken from a back camera. + if (!isBackCamera) { + canvas.context2D + ..translate(videoWidth, 0) + ..scale(-1, 1); + } canvas.context2D - ..translate(videoWidth, 0) - ..scale(-1, 1) - ..drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); + .drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); final blob = await canvas.toBlob('image/jpeg'); @@ -204,7 +211,7 @@ class Camera { /// /// Returns [Size.zero] if the camera is missing a video track or /// the video track does not include the width or height setting. - Future getVideoSize() async { + Size getVideoSize() { final videoTracks = videoElement.srcObject?.getVideoTracks() ?? []; if (videoTracks.isEmpty) { @@ -332,6 +339,29 @@ class Camera { }); } + /// Returns a lens direction of this camera. + /// + /// Returns null if the camera is missing a video track or + /// the video track does not include the facing mode setting. + CameraLensDirection? getLensDirection() { + final videoTracks = videoElement.srcObject?.getVideoTracks() ?? []; + + if (videoTracks.isEmpty) { + return null; + } + + final defaultVideoTrack = videoTracks.first; + final defaultVideoTrackSettings = defaultVideoTrack.getSettings(); + + final facingMode = defaultVideoTrackSettings['facingMode']; + + if (facingMode != null) { + return _cameraService.mapFacingModeToLensDirection(facingMode); + } else { + return null; + } + } + /// Returns the registered view type of the camera. String getViewType() => _getViewType(textureId); @@ -354,12 +384,18 @@ class Camera { /// Applies default styles to the video [element]. void _applyDefaultVideoStyles(html.VideoElement element) { + final isBackCamera = getLensDirection() == CameraLensDirection.back; + + // Flip the video horizontally if it is not taken from a back camera. + if (!isBackCamera) { + element.style.transform = 'scaleX(-1)'; + } + element.style ..transformOrigin = 'center' ..pointerEvents = 'none' ..width = '100%' ..height = '100%' - ..objectFit = 'cover' - ..transform = 'scaleX(-1)'; + ..objectFit = 'cover'; } } diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 9d349a788558..5c976b8f8657 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -285,7 +285,7 @@ class CameraPlugin extends CameraPlatform { ); }); - final cameraSize = await camera.getVideoSize(); + final cameraSize = camera.getVideoSize(); cameraEventStreamController.add( CameraInitializedEvent( From 5306c02db66b818fff39dc8a2d4eebd0257185fd Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 30 Aug 2021 14:51:58 -0400 Subject: [PATCH 243/364] [flutter_plugin_tool] Add support for running Windows unit tests (#4276) Implements support for `--windows` in `native-test`, for unit tests only. The structure of the new code has most of the new functionality in a generic utility for running GoogleTest test binaries, so that it can be trivially extended to Linux support in a follow-up once the Linux test PoC has landed. This runs the recently-added `url_launcher_windows` unit test. However, it's not yet run in CI since it needs LUCI bringup; that will be done one this support is in place. Requires new logic to check if a plugin contains native code, and some new test utility plumbing to generate plugins whose pubspecs indicate that they only contain Dart code to test it, to allow filtering filtering out the FFI-based Windows plugins. Part of flutter/flutter#82445 --- script/tool/CHANGELOG.md | 1 + script/tool/lib/src/common/file_utils.dart | 20 ++ script/tool/lib/src/common/plugin_utils.dart | 88 +++++--- script/tool/lib/src/native_test_command.dart | 89 +++++++- .../tool/lib/src/publish_plugin_command.dart | 12 +- script/tool/test/common/file_utils_test.dart | 32 +++ .../tool/test/common/plugin_utils_test.dart | 68 ++++++ .../tool/test/native_test_command_test.dart | 208 +++++++++++++++++- script/tool/test/util.dart | 73 +++--- 9 files changed, 510 insertions(+), 81 deletions(-) create mode 100644 script/tool/lib/src/common/file_utils.dart create mode 100644 script/tool/test/common/file_utils_test.dart diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 634360461c8d..0edb106f099c 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -5,6 +5,7 @@ - Pubspec validation now checks for `implements` in implementation packages. - Pubspec valitation now checks the full relative path of `repository` entries. - `build-examples` now supports UWP plugins via a `--winuwp` flag. +- `native-test` now supports `--windows` for unit tests. - **Breaking change**: `publish` no longer accepts `--no-tag-release` or `--no-push-flags`. Releases now always tag and push. - **Breaking change**: `publish`'s `--package` flag has been replaced with the diff --git a/script/tool/lib/src/common/file_utils.dart b/script/tool/lib/src/common/file_utils.dart new file mode 100644 index 000000000000..3c2f2f18f954 --- /dev/null +++ b/script/tool/lib/src/common/file_utils.dart @@ -0,0 +1,20 @@ +// Copyright 2013 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 'package:file/file.dart'; + +/// Returns a [File] created by appending all but the last item in [components] +/// to [base] as subdirectories, then appending the last as a file. +/// +/// Example: +/// childFileWithSubcomponents(rootDir, ['foo', 'bar', 'baz.txt']) +/// creates a File representing /rootDir/foo/bar/baz.txt. +File childFileWithSubcomponents(Directory base, List components) { + Directory dir = base; + final String basename = components.removeLast(); + for (final String directoryName in components) { + dir = dir.childDirectory(directoryName); + } + return dir.childFile(basename); +} diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart index 49da67655e91..06af675e71ef 100644 --- a/script/tool/lib/src/common/plugin_utils.dart +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -17,7 +17,7 @@ enum PlatformSupport { federated, } -/// Returns whether the given [package] is a Flutter [platform] plugin. +/// Returns true if [package] is a Flutter [platform] plugin. /// /// It checks this by looking for the following pattern in the pubspec: /// @@ -30,7 +30,7 @@ enum PlatformSupport { /// implementation in order to return true. bool pluginSupportsPlatform( String platform, - RepositoryPackage package, { + RepositoryPackage plugin, { PlatformSupport? requiredMode, String? variant, }) { @@ -41,32 +41,12 @@ bool pluginSupportsPlatform( platform == kPlatformWindows || platform == kPlatformLinux); try { - final YamlMap pubspecYaml = - loadYaml(package.pubspecFile.readAsStringSync()) as YamlMap; - final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; - if (flutterSection == null) { - return false; - } - final YamlMap? pluginSection = flutterSection['plugin'] as YamlMap?; - if (pluginSection == null) { - return false; - } - final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; - if (platforms == null) { - // Legacy plugin specs are assumed to support iOS and Android. They are - // never federated. - if (requiredMode == PlatformSupport.federated) { - return false; - } - if (!pluginSection.containsKey('platforms')) { - return platform == kPlatformIos || platform == kPlatformAndroid; - } - return false; - } - final YamlMap? platformEntry = platforms[platform] as YamlMap?; + final YamlMap? platformEntry = + _readPlatformPubspecSectionForPlugin(platform, plugin); if (platformEntry == null) { return false; } + // If the platform entry is present, then it supports the platform. Check // for required mode if specified. if (requiredMode != null) { @@ -97,9 +77,67 @@ bool pluginSupportsPlatform( } return true; + } on YamlException { + return false; + } +} + +/// Returns true if [plugin] includes native code for [platform], as opposed to +/// being implemented entirely in Dart. +bool pluginHasNativeCodeForPlatform(String platform, RepositoryPackage plugin) { + if (platform == kPlatformWeb) { + // Web plugins are always Dart-only. + return false; + } + try { + final YamlMap? platformEntry = + _readPlatformPubspecSectionForPlugin(platform, plugin); + if (platformEntry == null) { + return false; + } + // All other platforms currently use pluginClass for indicating the native + // code in the plugin. + final String? pluginClass = platformEntry['pluginClass'] as String?; + // TODO(stuartmorgan): Remove the check for 'none' once none of the plugins + // in the repository use that workaround. See + // https://github.com/flutter/flutter/issues/57497 for context. + return pluginClass != null && pluginClass != 'none'; } on FileSystemException { return false; } on YamlException { return false; } } + +/// Returns the +/// flutter: +/// plugin: +/// platforms: +/// [platform]: +/// section from [plugin]'s pubspec.yaml, or null if either it is not present, +/// or the pubspec couldn't be read. +YamlMap? _readPlatformPubspecSectionForPlugin( + String platform, RepositoryPackage plugin) { + try { + final File pubspecFile = plugin.pubspecFile; + final YamlMap pubspecYaml = + loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; + if (flutterSection == null) { + return null; + } + final YamlMap? pluginSection = flutterSection['plugin'] as YamlMap?; + if (pluginSection == null) { + return null; + } + final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; + if (platforms == null) { + return null; + } + return platforms[platform] as YamlMap?; + } on FileSystemException { + return null; + } on YamlException { + return null; + } +} diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 725cf23a2e9a..5120ad10b872 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -40,6 +40,7 @@ class NativeTestCommand extends PackageLoopingCommand { argParser.addFlag(kPlatformAndroid, help: 'Runs Android tests'); argParser.addFlag(kPlatformIos, help: 'Runs iOS tests'); argParser.addFlag(kPlatformMacos, help: 'Runs macOS tests'); + argParser.addFlag(kPlatformWindows, help: 'Runs Windows tests'); // By default, both unit tests and integration tests are run, but provide // flags to disable one or the other. @@ -80,6 +81,7 @@ this command. kPlatformAndroid: _PlatformDetails('Android', _testAndroid), kPlatformIos: _PlatformDetails('iOS', _testIos), kPlatformMacos: _PlatformDetails('macOS', _testMacOS), + kPlatformWindows: _PlatformDetails('Windows', _testWindows), }; _requestedPlatforms = _platforms.keys .where((String platform) => getBoolArg(platform)) @@ -96,6 +98,11 @@ this command. throw ToolExit(exitInvalidArguments); } + if (getBoolArg(kPlatformWindows) && getBoolArg(_integrationTestFlag)) { + logWarning('This command currently only supports unit tests for Windows. ' + 'See https://github.com/flutter/flutter/issues/70233.'); + } + // iOS-specific run-level state. if (_requestedPlatforms.contains('ios')) { String destination = getStringArg(_iosDestinationFlag); @@ -119,16 +126,20 @@ this command. Future runForPackage(RepositoryPackage package) async { final List testPlatforms = []; for (final String platform in _requestedPlatforms) { - if (pluginSupportsPlatform(platform, package, + if (!pluginSupportsPlatform(platform, package, requiredMode: PlatformSupport.inline)) { - testPlatforms.add(platform); - } else { print('No implementation for ${_platforms[platform]!.label}.'); + continue; } + if (!pluginHasNativeCodeForPlatform(platform, package)) { + print('No native code for ${_platforms[platform]!.label}.'); + continue; + } + testPlatforms.add(platform); } if (testPlatforms.isEmpty) { - return PackageResult.skip('Not implemented for target platform(s).'); + return PackageResult.skip('Nothing to test for target platform(s).'); } final _TestMode mode = _TestMode( @@ -228,6 +239,8 @@ this command. final bool hasIntegrationTests = exampleHasNativeIntegrationTests(example); + // TODO(stuartmorgan): Make !hasUnitTests fatal. See + // https://github.com/flutter/flutter/issues/85469 if (mode.unit && !hasUnitTests) { _printNoExampleTestsMessage(example, 'Android unit'); } @@ -335,6 +348,9 @@ this command. for (final RepositoryPackage example in plugin.getExamples()) { final String exampleName = example.displayName; + // TODO(stuartmorgan): Always check for RunnerTests, and make it fatal if + // no examples have it. See + // https://github.com/flutter/flutter/issues/85469 if (testTarget != null) { final Directory project = example.directory .childDirectory(platform.toLowerCase()) @@ -387,6 +403,71 @@ this command. return _PlatformResult(overallResult); } + Future<_PlatformResult> _testWindows( + RepositoryPackage plugin, _TestMode mode) async { + if (mode.integrationOnly) { + return _PlatformResult(RunState.skipped); + } + + bool isTestBinary(File file) { + return file.basename.endsWith('_test.exe') || + file.basename.endsWith('_tests.exe'); + } + + return _runGoogleTestTests(plugin, + buildDirectoryName: 'windows', isTestBinary: isTestBinary); + } + + /// Finds every file in the [buildDirectoryName] subdirectory of [plugin]'s + /// build directory for which [isTestBinary] is true, and runs all of them, + /// returning the overall result. + /// + /// The binaries are assumed to be Google Test test binaries, thus returning + /// zero for success and non-zero for failure. + Future<_PlatformResult> _runGoogleTestTests( + RepositoryPackage plugin, { + required String buildDirectoryName, + required bool Function(File) isTestBinary, + }) async { + final List testBinaries = []; + for (final RepositoryPackage example in plugin.getExamples()) { + final Directory buildDir = example.directory + .childDirectory('build') + .childDirectory(buildDirectoryName); + if (!buildDir.existsSync()) { + continue; + } + testBinaries.addAll(buildDir + .listSync(recursive: true) + .whereType() + .where(isTestBinary) + .where((File file) { + // Only run the debug build of the unit tests, to avoid running the + // same tests multiple times. + final List components = path.split(file.path); + return components.contains('debug') || components.contains('Debug'); + })); + } + + if (testBinaries.isEmpty) { + final String binaryExtension = platform.isWindows ? '.exe' : ''; + printError( + 'No test binaries found. At least one *_test(s)$binaryExtension ' + 'binary should be built by the example(s)'); + return _PlatformResult(RunState.failed, + error: 'No $buildDirectoryName unit tests found'); + } + + bool passing = true; + for (final File test in testBinaries) { + print('Running ${test.basename}...'); + final int exitCode = + await processRunner.runAndStream(test.path, []); + passing &= exitCode == 0; + } + return _PlatformResult(passing ? RunState.succeeded : RunState.failed); + } + /// Prints a standard format message indicating that [platform] tests for /// [plugin]'s [example] are about to be run. void _printRunningExampleTestsMessage( diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index aafe7868d8d0..e210152ecf09 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -18,6 +18,7 @@ import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:yaml/yaml.dart'; import 'common/core.dart'; +import 'common/file_utils.dart'; import 'common/git_version_finder.dart'; import 'common/plugin_command.dart'; import 'common/process_runner.dart'; @@ -154,13 +155,10 @@ class PublishPluginCommand extends PluginCommand { await gitVersionFinder.getChangedPubSpecs(); for (final String pubspecPath in changedPubspecs) { - // Convert git's Posix-style paths to a path that matches the current - // filesystem. - final String localStylePubspecPath = - path.joinAll(p.posix.split(pubspecPath)); - final File pubspecFile = packagesDir.fileSystem - .directory((await gitDir).path) - .childFile(localStylePubspecPath); + // git outputs a relativa, Posix-style path. + final File pubspecFile = childFileWithSubcomponents( + packagesDir.fileSystem.directory((await gitDir).path), + p.posix.split(pubspecPath)); yield PackageEnumerationEntry(RepositoryPackage(pubspecFile.parent), excluded: false); } diff --git a/script/tool/test/common/file_utils_test.dart b/script/tool/test/common/file_utils_test.dart new file mode 100644 index 000000000000..e3986842a969 --- /dev/null +++ b/script/tool/test/common/file_utils_test.dart @@ -0,0 +1,32 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/file_utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('works on Posix', () async { + final FileSystem fileSystem = + MemoryFileSystem(style: FileSystemStyle.posix); + + final Directory base = fileSystem.directory('/').childDirectory('base'); + final File file = + childFileWithSubcomponents(base, ['foo', 'bar', 'baz.txt']); + + expect(file.absolute.path, '/base/foo/bar/baz.txt'); + }); + + test('works on Windows', () async { + final FileSystem fileSystem = + MemoryFileSystem(style: FileSystemStyle.windows); + + final Directory base = fileSystem.directory(r'C:\').childDirectory('base'); + final File file = + childFileWithSubcomponents(base, ['foo', 'bar', 'baz.txt']); + + expect(file.absolute.path, r'C:\base\foo\bar\baz.txt'); + }); +} diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart index 2e08f725eb4b..ac619e2622e0 100644 --- a/script/tool/test/common/plugin_utils_test.dart +++ b/script/tool/test/common/plugin_utils_test.dart @@ -273,4 +273,72 @@ void main() { isTrue); }); }); + + group('pluginHasNativeCodeForPlatform', () { + test('returns false for web', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), + }, + )); + + expect(pluginHasNativeCodeForPlatform(kPlatformWeb, plugin), isFalse); + }); + + test('returns false for a native-only plugin', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }, + )); + + expect(pluginHasNativeCodeForPlatform(kPlatformLinux, plugin), isTrue); + expect(pluginHasNativeCodeForPlatform(kPlatformMacos, plugin), isTrue); + expect(pluginHasNativeCodeForPlatform(kPlatformWindows, plugin), isTrue); + }); + + test('returns true for a native+Dart plugin', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline, + hasNativeCode: true, hasDartCode: true), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline, + hasNativeCode: true, hasDartCode: true), + kPlatformWindows: const PlatformDetails(PlatformSupport.inline, + hasNativeCode: true, hasDartCode: true), + }, + )); + + expect(pluginHasNativeCodeForPlatform(kPlatformLinux, plugin), isTrue); + expect(pluginHasNativeCodeForPlatform(kPlatformMacos, plugin), isTrue); + expect(pluginHasNativeCodeForPlatform(kPlatformWindows, plugin), isTrue); + }); + + test('returns false for a Dart-only plugin', () async { + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline, + hasNativeCode: false, hasDartCode: true), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline, + hasNativeCode: false, hasDartCode: true), + kPlatformWindows: const PlatformDetails(PlatformSupport.inline, + hasNativeCode: false, hasDartCode: true), + }, + )); + + expect(pluginHasNativeCodeForPlatform(kPlatformLinux, plugin), isFalse); + expect(pluginHasNativeCodeForPlatform(kPlatformMacos, plugin), isFalse); + expect(pluginHasNativeCodeForPlatform(kPlatformWindows, plugin), isFalse); + }); + }); } diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index 7b2a3d3ba39c..3613a808d9b8 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -9,6 +9,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/file_utils.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/native_test_command.dart'; import 'package:test/test.dart'; @@ -57,7 +58,7 @@ final Map _kDeviceListMap = { void main() { const String _kDestination = '--ios-destination'; - group('test native_test_command', () { + group('test native_test_command on Posix', () { late FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; @@ -164,7 +165,7 @@ void main() { output, containsAllInOrder([ contains('No implementation for iOS.'), - contains('SKIPPING: Not implemented for target platform(s).'), + contains('SKIPPING: Nothing to test for target platform(s).'), ])); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -181,7 +182,7 @@ void main() { output, containsAllInOrder([ contains('No implementation for iOS.'), - contains('SKIPPING: Not implemented for target platform(s).'), + contains('SKIPPING: Nothing to test for target platform(s).'), ])); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -291,7 +292,7 @@ void main() { output, containsAllInOrder([ contains('No implementation for macOS.'), - contains('SKIPPING: Not implemented for target platform(s).'), + contains('SKIPPING: Nothing to test for target platform(s).'), ])); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -309,7 +310,7 @@ void main() { output, containsAllInOrder([ contains('No implementation for macOS.'), - contains('SKIPPING: Not implemented for target platform(s).'), + contains('SKIPPING: Nothing to test for target platform(s).'), ])); expect(processRunner.recordedCalls, orderedEquals([])); }); @@ -707,7 +708,7 @@ void main() { output, containsAllInOrder([ contains('No implementation for Android.'), - contains('SKIPPING: Not implemented for target platform(s).'), + contains('SKIPPING: Nothing to test for target platform(s).'), ]), ); }); @@ -1173,6 +1174,7 @@ void main() { '--android', '--ios', '--macos', + '--windows', _kDestination, 'foo_destination', ]); @@ -1183,7 +1185,38 @@ void main() { contains('No implementation for Android.'), contains('No implementation for iOS.'), contains('No implementation for macOS.'), - contains('SKIPPING: Not implemented for target platform(s).'), + contains('SKIPPING: Nothing to test for target platform(s).'), + ])); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skips Dart-only plugins', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline, + hasDartCode: true, hasNativeCode: false), + kPlatformWindows: const PlatformDetails(PlatformSupport.inline, + hasDartCode: true, hasNativeCode: false), + }, + ); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--windows', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('No native code for macOS.'), + contains('No native code for Windows.'), + contains('SKIPPING: Nothing to test for target platform(s).'), ])); expect(processRunner.recordedCalls, orderedEquals([])); @@ -1295,4 +1328,165 @@ void main() { }); }); }); + + group('test native_test_command on Windows', () { + late FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(style: FileSystemStyle.windows); + mockPlatform = MockPlatform(isWindows: true); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); + final NativeTestCommand command = NativeTestCommand(packagesDir, + processRunner: processRunner, platform: mockPlatform); + + runner = CommandRunner( + 'native_test_command', 'Test for native_test_command'); + runner.addCommand(command); + }); + + group('Windows', () { + test('runs unit tests', () async { + const String testBinaryRelativePath = + 'build/windows/foo/Debug/bar/plugin_test.exe'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/$testBinaryRelativePath' + ], platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }); + + final File testBinary = childFileWithSubcomponents(pluginDirectory, + ['example', ...testBinaryRelativePath.split('/')]); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--windows', + '--no-integration', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running plugin_test.exe...'), + contains('No issues found!'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall(testBinary.path, const [], null), + ])); + }); + + test('only runs debug unit tests', () async { + const String debugTestBinaryRelativePath = + 'build/windows/foo/Debug/bar/plugin_test.exe'; + const String releaseTestBinaryRelativePath = + 'build/windows/foo/Release/bar/plugin_test.exe'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/$debugTestBinaryRelativePath', + 'example/$releaseTestBinaryRelativePath' + ], platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }); + + final File debugTestBinary = childFileWithSubcomponents(pluginDirectory, + ['example', ...debugTestBinaryRelativePath.split('/')]); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--windows', + '--no-integration', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running plugin_test.exe...'), + contains('No issues found!'), + ]), + ); + + // Only the debug version should be run. + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall(debugTestBinary.path, const [], null), + ])); + }); + + test('fails if there are no unit tests', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--windows', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No test binaries found.'), + ]), + ); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('fails if a unit test fails', () async { + const String testBinaryRelativePath = + 'build/windows/foo/Debug/bar/plugin_test.exe'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/$testBinaryRelativePath' + ], platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }); + + final File testBinary = childFileWithSubcomponents(pluginDirectory, + ['example', ...testBinaryRelativePath.split('/')]); + + processRunner.mockProcessesForExecutable[testBinary.path] = + [MockProcess(exitCode: 1)]; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--windows', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running plugin_test.exe...'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall(testBinary.path, const [], null), + ])); + }); + }); + }); } diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 74c036489233..e053100172c6 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -10,6 +10,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/file_utils.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:meta/meta.dart'; @@ -47,6 +48,8 @@ class PlatformDetails { const PlatformDetails( this.type, { this.variants = const [], + this.hasNativeCode = true, + this.hasDartCode = false, }); /// The type of support for the platform. @@ -54,6 +57,16 @@ class PlatformDetails { /// Any 'supportVariants' to list in the pubspec. final List variants; + + /// Whether or not the plugin includes native code. + /// + /// Ignored for web, which does not have native code. + final bool hasNativeCode; + + /// Whether or not the plugin includes Dart code. + /// + /// Ignored for web, which always has native code. + final bool hasDartCode; } /// Creates a plugin package with the given [name] in [packagesDirectory]. @@ -130,15 +143,10 @@ Directory createFakePackage( } } - final FileSystem fileSystem = packageDirectory.fileSystem; final p.Context posixContext = p.posix; for (final String file in extraFiles) { - final List newFilePath = [ - packageDirectory.path, - ...posixContext.split(file) - ]; - final File newFile = fileSystem.file(fileSystem.path.joinAll(newFilePath)); - newFile.createSync(recursive: true); + childFileWithSubcomponents(packageDirectory, posixContext.split(file)) + .createSync(recursive: true); } return packageDirectory; @@ -210,49 +218,38 @@ String _pluginPlatformSection( default_package: ${packageName}_$platform '''; } else { + final List lines = [ + ' $platform:', + ]; switch (platform) { case kPlatformAndroid: - entry = ''' - android: - package: io.flutter.plugins.fake - pluginClass: FakePlugin -'''; - break; + lines.add(' package: io.flutter.plugins.fake'); + continue nativeByDefault; + nativeByDefault: case kPlatformIos: - entry = ''' - ios: - pluginClass: FLTFakePlugin -'''; - break; case kPlatformLinux: - entry = ''' - linux: - pluginClass: FakePlugin -'''; - break; case kPlatformMacos: - entry = ''' - macos: - pluginClass: FakePlugin -'''; + case kPlatformWindows: + if (support.hasNativeCode) { + final String className = + platform == kPlatformIos ? 'FLTFakePlugin' : 'FakePlugin'; + lines.add(' pluginClass: $className'); + } + if (support.hasDartCode) { + lines.add(' dartPluginClass: FakeDartPlugin'); + } break; case kPlatformWeb: - entry = ''' - web: - pluginClass: FakePlugin - fileName: ${packageName}_web.dart -'''; - break; - case kPlatformWindows: - entry = ''' - windows: - pluginClass: FakePlugin -'''; + lines.addAll([ + ' pluginClass: FakePlugin', + ' fileName: ${packageName}_web.dart', + ]); break; default: assert(false, 'Unrecognized platform: $platform'); break; } + entry = lines.join('\n') + '\n'; } // Add any variants. From ffe53ec2f135ae01fa1a691c01d5672fe54a0392 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 31 Aug 2021 10:54:41 -0400 Subject: [PATCH 244/364] [flutter_plugin_tool] Move branch-switching logic from tool_runner.sh to tool (#4268) Eliminates the remaining logic from tool_runner.sh, completing the goal of migrating repository tooling off of bash (both to make maintenance easier, and to better support Windows both locally and in CI). Its branch-based logic is now part of the tool itself, via a new `--packages-for-branch` flag (which is hidden in help since it's only useful for CI). Part of https://github.com/flutter/flutter/issues/86113 --- script/tool/CHANGELOG.md | 4 +- .../tool/lib/src/common/plugin_command.dart | 90 +++++-- script/tool/pubspec.yaml | 2 +- .../tool/test/common/plugin_command_test.dart | 247 +++++++++++++----- script/tool_runner.sh | 31 +-- 5 files changed, 271 insertions(+), 103 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 0edb106f099c..2ef34c184b11 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,4 +1,4 @@ -## NEXT +## 0.6.0 - Added Android native integration test support to `native-test`. - Added a new `android-lint` command to lint Android plugin native code. @@ -10,6 +10,8 @@ `--no-push-flags`. Releases now always tag and push. - **Breaking change**: `publish`'s `--package` flag has been replaced with the `--packages` flag used by most other packages. +- **Breaking change** Passing both `--run-on-changed-packages` and `--packages` + is now an error; previously it the former would be ignored. ## 0.5.0 diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index ec51261ab617..514a90b85cc7 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.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:io' as io; import 'dart:math'; import 'package:args/command_runner.dart'; @@ -72,11 +73,18 @@ abstract class PluginCommand extends Command { ); argParser.addFlag(_runOnChangedPackagesArg, help: 'Run the command on changed packages/plugins.\n' - 'If the $_packagesArg is specified, this flag is ignored.\n' 'If no packages have changed, or if there have been changes that may\n' 'affect all packages, the command runs on all packages.\n' 'The packages excluded with $_excludeArg is also excluded even if changed.\n' - 'See $_kBaseSha if a custom base is needed to determine the diff.'); + 'See $_kBaseSha if a custom base is needed to determine the diff.\n\n' + 'Cannot be combined with $_packagesArg.\n'); + argParser.addFlag(_packagesForBranchArg, + help: + 'This runs on all packages (equivalent to no package selection flag)\n' + 'on master, and behaves like --run-on-changed-packages on any other branch.\n\n' + 'Cannot be combined with $_packagesArg.\n\n' + 'This is intended for use in CI.\n', + hide: true); argParser.addOption(_kBaseSha, help: 'The base sha used to determine git diff. \n' 'This is useful when $_runOnChangedPackagesArg is specified.\n' @@ -89,6 +97,7 @@ abstract class PluginCommand extends Command { static const String _shardCountArg = 'shardCount'; static const String _excludeArg = 'exclude'; static const String _runOnChangedPackagesArg = 'run-on-changed-packages'; + static const String _packagesForBranchArg = 'packages-for-branch'; static const String _kBaseSha = 'base-sha'; /// The directory containing the plugin packages. @@ -266,15 +275,50 @@ abstract class PluginCommand extends Command { /// is a sibling of the packages directory. This is used for a small number /// of packages in the flutter/packages repository. Stream _getAllPackages() async* { + final Set packageSelectionFlags = { + _packagesArg, + _runOnChangedPackagesArg, + _packagesForBranchArg, + }; + if (packageSelectionFlags + .where((String flag) => argResults!.wasParsed(flag)) + .length > + 1) { + printError('Only one of --$_packagesArg, --$_runOnChangedPackagesArg, or ' + '--$_packagesForBranchArg can be provided.'); + throw ToolExit(exitInvalidArguments); + } + Set plugins = Set.from(getStringListArg(_packagesArg)); + final bool runOnChangedPackages; + if (getBoolArg(_runOnChangedPackagesArg)) { + runOnChangedPackages = true; + } else if (getBoolArg(_packagesForBranchArg)) { + final String? branch = await _getBranch(); + if (branch == null) { + printError('Unabled to determine branch; --$_packagesForBranchArg can ' + 'only be used in a git repository.'); + throw ToolExit(exitInvalidArguments); + } else { + runOnChangedPackages = branch != 'master'; + // Log the mode for auditing what was intended to run. + print('--$_packagesForBranchArg: running on ' + '${runOnChangedPackages ? 'changed' : 'all'} packages'); + } + } else { + runOnChangedPackages = false; + } + final Set excludedPluginNames = getExcludedPackageNames(); - final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); - if (plugins.isEmpty && - runOnChangedPackages && - !(await _changesRequireFullTest())) { - plugins = await _getChangedPackages(); + if (runOnChangedPackages) { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final List changedFiles = + await gitVersionFinder.getChangedFiles(); + if (!_changesRequireFullTest(changedFiles)) { + plugins = _getChangedPackages(changedFiles); + } } final Directory thirdPartyPackagesDirectory = packagesDir.parent @@ -374,15 +418,13 @@ abstract class PluginCommand extends Command { return gitVersionFinder; } - // Returns packages that have been changed relative to the git base. - Future> _getChangedPackages() async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); - - final List allChangedFiles = - await gitVersionFinder.getChangedFiles(); + // Returns packages that have been changed given a list of changed files. + // + // The paths must use POSIX separators (e.g., as provided by git output). + Set _getChangedPackages(List changedFiles) { final Set packages = {}; - for (final String path in allChangedFiles) { - final List pathComponents = path.split('/'); + for (final String path in changedFiles) { + final List pathComponents = p.posix.split(path); final int packagesIndex = pathComponents.indexWhere((String element) => element == 'packages'); if (packagesIndex != -1) { @@ -398,11 +440,19 @@ abstract class PluginCommand extends Command { return packages; } + Future _getBranch() async { + final io.ProcessResult branchResult = await (await gitDir).runCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + throwOnError: false); + if (branchResult.exitCode != 0) { + return null; + } + return (branchResult.stdout as String).trim(); + } + // Returns true if one or more files changed that have the potential to affect // any plugin (e.g., CI script changes). - Future _changesRequireFullTest() async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); - + bool _changesRequireFullTest(List changedFiles) { const List specialFiles = [ '.ci.yaml', // LUCI config. '.cirrus.yml', // Cirrus config. @@ -417,9 +467,7 @@ abstract class PluginCommand extends Command { // check below is done via string prefixing. assert(specialDirectories.every((String dir) => dir.endsWith('/'))); - final List allChangedFiles = - await gitVersionFinder.getChangedFiles(); - return allChangedFiles.any((String path) => + return changedFiles.any((String path) => specialFiles.contains(path) || specialDirectories.any((String dir) => path.startsWith(dir))); } diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 02b3ca624b96..7c2bb0b3e3c0 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.5.0 +version: 0.6.0 dependencies: args: ^2.1.0 diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 10bdff4e9c56..3ef0d3b3c005 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/plugin_command.dart'; import 'package:flutter_plugin_tools/src/common/process_runner.dart'; import 'package:git/git.dart'; @@ -28,8 +29,6 @@ void main() { late MockPlatform mockPlatform; late Directory packagesDir; late Directory thirdPartyPackagesDir; - late List?> gitDirCommands; - late String gitDiffResponse; setUp(() { fileSystem = MemoryFileSystem(); @@ -39,18 +38,15 @@ void main() { .childDirectory('third_party') .childDirectory('packages'); - gitDirCommands = ?>[]; - gitDiffResponse = ''; final MockGitDir gitDir = MockGitDir(); when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) .thenAnswer((Invocation invocation) { - gitDirCommands.add(invocation.positionalArguments[0] as List?); - final MockProcessResult mockProcessResult = MockProcessResult(); - if (invocation.positionalArguments[0][0] == 'diff') { - when(mockProcessResult.stdout as String?) - .thenReturn(gitDiffResponse); - } - return Future.value(mockProcessResult); + final List arguments = + invocation.positionalArguments[0]! as List; + // Attach the first argument to the command to make targeting the mock + // results easier. + final String gitCommand = arguments.removeAt(0); + return processRunner.run('git-$gitCommand', arguments); }); processRunner = RecordingProcessRunner(); command = SamplePluginCommand( @@ -184,6 +180,68 @@ void main() { expect(command.plugins, unorderedEquals([])); }); + group('conflicting package selection', () { + test('does not allow --packages with --run-on-changed-packages', + () async { + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'sample', + '--run-on-changed-packages', + '--packages=plugin1', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Only one of --packages, --run-on-changed-packages, or ' + '--packages-for-branch can be provided.') + ])); + }); + + test('does not allow --packages with --packages-for-branch', () async { + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'sample', + '--packages-for-branch', + '--packages=plugin1', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Only one of --packages, --run-on-changed-packages, or ' + '--packages-for-branch can be provided.') + ])); + }); + + test( + 'does not allow --run-on-changed-packages with --packages-for-branch', + () async { + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'sample', + '--packages-for-branch', + '--packages=plugin1', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Only one of --packages, --run-on-changed-packages, or ' + '--packages-for-branch can be provided.') + ])); + }); + }); + group('test run-on-changed-packages', () { test('all plugins should be tested if there are no changes.', () async { final Directory plugin1 = createFakePlugin('plugin1', packagesDir); @@ -201,7 +259,9 @@ void main() { test( 'all plugins should be tested if there are no plugin related changes.', () async { - gitDiffResponse = 'AUTHORS'; + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'AUTHORS'), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -215,10 +275,12 @@ void main() { }); test('all plugins should be tested if .cirrus.yml changes.', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' .cirrus.yml packages/plugin1/CHANGELOG -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -232,10 +294,12 @@ packages/plugin1/CHANGELOG }); test('all plugins should be tested if .ci.yaml changes', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' .ci.yaml packages/plugin1/CHANGELOG -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -250,10 +314,12 @@ packages/plugin1/CHANGELOG test('all plugins should be tested if anything in .ci/ changes', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' .ci/Dockerfile packages/plugin1/CHANGELOG -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -268,10 +334,12 @@ packages/plugin1/CHANGELOG test('all plugins should be tested if anything in script changes.', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' script/tool_runner.sh packages/plugin1/CHANGELOG -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -286,10 +354,12 @@ packages/plugin1/CHANGELOG test('all plugins should be tested if the root analysis options change.', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' analysis_options.yaml packages/plugin1/CHANGELOG -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -304,10 +374,12 @@ packages/plugin1/CHANGELOG test('all plugins should be tested if formatting options change.', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' .clang-format packages/plugin1/CHANGELOG -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -321,7 +393,9 @@ packages/plugin1/CHANGELOG }); test('Only changed plugin should be tested.', () async { - gitDiffResponse = 'packages/plugin1/plugin1.dart'; + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'packages/plugin1/plugin1.dart'), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -335,10 +409,12 @@ packages/plugin1/CHANGELOG test('multiple files in one plugin should also test the plugin', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' packages/plugin1/plugin1.dart packages/plugin1/ios/plugin1.m -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); await runCapturingPrint(runner, [ @@ -352,10 +428,12 @@ packages/plugin1/ios/plugin1.m test('multiple plugins changed should test all the changed plugins', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); final Directory plugin2 = createFakePlugin('plugin2', packagesDir); createFakePlugin('plugin3', packagesDir); @@ -372,11 +450,13 @@ packages/plugin2/ios/plugin2.m test( 'multiple plugins inside the same plugin group changed should output the plugin group name', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' packages/plugin1/plugin1/plugin1.dart packages/plugin1/plugin1_platform_interface/plugin1_platform_interface.dart packages/plugin1/plugin1_web/plugin1_web.dart -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); @@ -393,9 +473,11 @@ packages/plugin1/plugin1_web/plugin1_web.dart test( 'changing one plugin in a federated group should include all plugins in the group', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' packages/plugin1/plugin1/plugin1.dart -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); final Directory plugin2 = createFakePlugin('plugin1_platform_interface', @@ -414,35 +496,14 @@ packages/plugin1/plugin1/plugin1.dart [plugin1.path, plugin2.path, plugin3.path])); }); - test( - '--packages flag overrides the behavior of --run-on-changed-packages', - () async { - gitDiffResponse = ''' -packages/plugin1/plugin1.dart -packages/plugin2/ios/plugin2.m -packages/plugin3/plugin3.dart -'''; - final Directory plugin1 = - createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - createFakePlugin('plugin3', packagesDir); - await runCapturingPrint(runner, [ - 'sample', - '--packages=plugin1,plugin2', - '--base-sha=master', - '--run-on-changed-packages' - ]); - - expect(command.plugins, - unorderedEquals([plugin1.path, plugin2.path])); - }); - test('--exclude flag works with --run-on-changed-packages', () async { - gitDiffResponse = ''' + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' packages/plugin1/plugin1.dart packages/plugin2/ios/plugin2.m packages/plugin3/plugin3.dart -'''; +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); createFakePlugin('plugin2', packagesDir); @@ -459,6 +520,74 @@ packages/plugin3/plugin3.dart }); }); + group('--packages-for-branch', () { + test('only tests changed packages on a branch', () async { + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'packages/plugin1/plugin1.dart'), + ]; + processRunner.mockProcessesForExecutable['git-rev-parse'] = [ + MockProcess(stdout: 'a-branch'), + ]; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + + final List output = await runCapturingPrint( + runner, ['sample', '--packages-for-branch']); + + expect(command.plugins, unorderedEquals([plugin1.path])); + expect( + output, + containsAllInOrder([ + contains('--packages-for-branch: running on changed packages'), + ])); + }); + + test('tests all packages on master', () async { + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'packages/plugin1/plugin1.dart'), + ]; + processRunner.mockProcessesForExecutable['git-rev-parse'] = [ + MockProcess(stdout: 'master'), + ]; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + + final List output = await runCapturingPrint( + runner, ['sample', '--packages-for-branch']); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + expect( + output, + containsAllInOrder([ + contains('--packages-for-branch: running on all packages'), + ])); + }); + + test('throws if getting the branch fails', () async { + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'packages/plugin1/plugin1.dart'), + ]; + processRunner.mockProcessesForExecutable['git-rev-parse'] = [ + MockProcess(exitCode: 1), + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['sample', '--packages-for-branch'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unabled to determine branch'), + ])); + }); + }); + group('sharding', () { test('distributes evenly when evenly divisible', () async { final List> expectedShards = >[ @@ -625,5 +754,3 @@ class SamplePluginCommand extends PluginCommand { } } } - -class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool_runner.sh b/script/tool_runner.sh index 93a7776d0a35..99bab387e6b6 100755 --- a/script/tool_runner.sh +++ b/script/tool_runner.sh @@ -5,27 +5,18 @@ set -e +# WARNING! Do not remove this script, or change its behavior, unless you have +# verified that it will not break the flutter/flutter analysis run of this +# repository: https://github.com/flutter/flutter/blob/master/dev/bots/test.dart + readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" +readonly TOOL_PATH="$REPO_DIR/script/tool/bin/flutter_plugin_tools.dart" -# Runs the plugin tools from the in-tree source. -function plugin_tools() { - (pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null - dart run "$REPO_DIR/script/tool/bin/flutter_plugin_tools.dart" "$@" -} - -ACTIONS=("$@") - -BRANCH_NAME="${BRANCH_NAME:-"$(git rev-parse --abbrev-ref HEAD)"}" - -# This has to be turned into a list and then split out to the command line, -# otherwise it gets treated as a single argument. -PLUGIN_SHARDING=($PLUGIN_SHARDING) +# Ensure that the tool dependencies have been fetched. +(pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null -if [[ "${BRANCH_NAME}" == "master" ]]; then - echo "Running for all packages" - (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" ${PLUGIN_SHARDING[@]}) -else - echo running "${ACTIONS[@]}" - (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" --run-on-changed-packages ${PLUGIN_SHARDING[@]}) -fi +# The tool expects to be run from the repo root. +cd "$REPO_DIR" +# Run from the in-tree source. +dart run "$TOOL_PATH" "$@" --packages-for-branch $PLUGIN_SHARDING From 5afbfe9bb7b57df299b1bcac7dbf4517823388cc Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 31 Aug 2021 13:55:01 -0400 Subject: [PATCH 245/364] [flutter_plugin_tool] Migrate 'publish' to new base command (#4290) Moves `publish` to PackageLoopingCommand, giving it the same standardized output and summary that is used by most other commands in the tool. Adds minor new functionality to the base command to allow it to handle the specific needs of publish: - Allows fully customizing the set of packages to loop over, to support --all-changed - Allows customization of a few more strings so the output better matches the functionality (e.g., using 'published' instead of 'ran' in the summary lines). Fixes https://github.com/flutter/flutter/issues/83413 --- .../src/common/package_looping_command.dart | 22 +- .../tool/lib/src/publish_plugin_command.dart | 239 ++++++------------ .../test/publish_plugin_command_test.dart | 140 +++++----- 3 files changed, 163 insertions(+), 238 deletions(-) diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 00caeb30ef42..96dd881bfe00 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -92,6 +92,18 @@ abstract class PackageLoopingCommand extends PluginCommand { /// arguments are invalid), and to set up any run-level state. Future initializeRun() async {} + /// Returns the packages to process. By default, this returns the packages + /// defined by the standard tooling flags and the [inculdeSubpackages] option, + /// but can be overridden for custom package enumeration. + /// + /// Note: Consistent behavior across commands whenever possibel is a goal for + /// this tool, so this should be overridden only in rare cases. + Stream getPackagesToProcess() async* { + yield* includeSubpackages + ? getTargetPackagesAndSubpackages(filterExcluded: false) + : getTargetPackages(filterExcluded: false); + } + /// Runs the command for [package], returning a list of errors. /// /// Errors may either be an empty string if there is no context that should @@ -138,6 +150,9 @@ abstract class PackageLoopingCommand extends PluginCommand { /// context. String get failureListFooter => 'See above for full details.'; + /// The summary string used for a successful run in the final overview output. + String get successSummaryMessage => 'ran'; + /// If true, all printing (including the summary) will be redirected to a /// buffer, and provided in a call to [handleCapturedOutput] at the end of /// the run. @@ -206,9 +221,8 @@ abstract class PackageLoopingCommand extends PluginCommand { await initializeRun(); - final List targetPackages = includeSubpackages - ? await getTargetPackagesAndSubpackages(filterExcluded: false).toList() - : await getTargetPackages(filterExcluded: false).toList(); + final List targetPackages = + await getPackagesToProcess().toList(); final Map results = {}; @@ -346,7 +360,7 @@ abstract class PackageLoopingCommand extends PluginCommand { summary = 'skipped'; style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY; } else { - summary = 'ran'; + summary = successSummaryMessage; style = hadWarning ? Styles.YELLOW : Styles.GREEN; } if (hadWarning) { diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index e210152ecf09..6da51706ef1e 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -7,7 +7,6 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; -import 'package:flutter_plugin_tools/src/common/repository_package.dart'; import 'package:git/git.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; @@ -20,9 +19,11 @@ import 'package:yaml/yaml.dart'; import 'common/core.dart'; import 'common/file_utils.dart'; import 'common/git_version_finder.dart'; +import 'common/package_looping_command.dart'; import 'common/plugin_command.dart'; import 'common/process_runner.dart'; import 'common/pub_version_finder.dart'; +import 'common/repository_package.dart'; @immutable class _RemoteInfo { @@ -46,7 +47,7 @@ class _RemoteInfo { /// usage information. /// /// [processRunner], [print], and [stdin] can be overriden for easier testing. -class PublishPluginCommand extends PluginCommand { +class PublishPluginCommand extends PackageLoopingCommand { /// Creates an instance of the publish command. PublishPluginCommand( Directory packagesDir, { @@ -117,38 +118,45 @@ class PublishPluginCommand extends PluginCommand { StreamSubscription? _stdinSubscription; final PubVersionFinder _pubVersionFinder; + // Tags that already exist in the repository. + List _existingGitTags = []; + // The remote to push tags to. + late _RemoteInfo _remote; + + @override + String get successSummaryMessage => 'published'; + + @override + String get failureListHeader => + 'The following packages had failures during publishing:'; + @override - Future run() async { + Future initializeRun() async { print('Checking local repo...'); - final GitDir repository = await gitDir; + + // Ensure that the requested remote is present. final String remoteName = getStringArg(_remoteOption); final String? remoteUrl = await _verifyRemote(remoteName); if (remoteUrl == null) { printError('Unable to find URL for remote $remoteName; cannot push tags'); throw ToolExit(1); } - final _RemoteInfo remote = _RemoteInfo(name: remoteName, url: remoteUrl); + _remote = _RemoteInfo(name: remoteName, url: remoteUrl); + + // Pre-fetch all the repository's tags, to check against when publishing. + final GitDir repository = await gitDir; + final io.ProcessResult existingTagsResult = + await repository.runCommand(['tag', '--sort=-committerdate']); + _existingGitTags = (existingTagsResult.stdout as String).split('\n') + ..removeWhere((String element) => element.isEmpty); - print('Local repo is ready!'); if (getBoolArg(_dryRunFlag)) { - print('=============== DRY RUN ==============='); + print('=============== DRY RUN ==============='); } - - final List packages = await _getPackagesToProcess() - .where((PackageEnumerationEntry entry) => !entry.excluded) - .toList(); - bool successful = true; - - successful = await _publishPackages( - packages, - baseGitDir: repository, - remoteForTagPush: remote, - ); - - await _finish(successful); } - Stream _getPackagesToProcess() async* { + @override + Stream getPackagesToProcess() async* { if (getBoolArg(_allChangedFlag)) { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); final List changedPubspecs = @@ -167,92 +175,52 @@ class PublishPluginCommand extends PluginCommand { } } - Future _publishPackages( - List packages, { - required GitDir baseGitDir, - required _RemoteInfo remoteForTagPush, - }) async { - if (packages.isEmpty) { - print('No version updates in this commit.'); - return true; + @override + Future runForPackage(RepositoryPackage package) async { + final PackageResult? checkResult = await _checkNeedsRelease(package); + if (checkResult != null) { + return checkResult; } - final io.ProcessResult existingTagsResult = - await baseGitDir.runCommand(['tag', '--sort=-committerdate']); - final List existingTags = (existingTagsResult.stdout as String) - .split('\n') - ..removeWhere((String element) => element.isEmpty); - - final List packagesReleased = []; - final List packagesFailed = []; - - for (final PackageEnumerationEntry entry in packages) { - final RepositoryPackage package = entry.package; - - final _CheckNeedsReleaseResult result = await _checkNeedsRelease( - package: package, - existingTags: existingTags, - ); - switch (result) { - case _CheckNeedsReleaseResult.release: - break; - case _CheckNeedsReleaseResult.noRelease: - continue; - case _CheckNeedsReleaseResult.failure: - packagesFailed.add(package.displayName); - continue; - } - print('\n'); - if (await _publishAndTagPackage(package, - remoteForTagPush: remoteForTagPush)) { - packagesReleased.add(package.displayName); - } else { - packagesFailed.add(package.displayName); - } - print('\n'); + if (!await _checkGitStatus(package)) { + return PackageResult.fail(['uncommitted changes']); } - if (packagesReleased.isNotEmpty) { - print('Packages released: ${packagesReleased.join(', ')}'); + + if (!await _publish(package)) { + return PackageResult.fail(['publish failed']); } - if (packagesFailed.isNotEmpty) { - printError( - 'Failed to release the following packages: ${packagesFailed.join(', ')}, see above for details.'); + + if (!await _tagRelease(package)) { + return PackageResult.fail(['tagging failed']); } - return packagesFailed.isEmpty; + + print('\nPublished ${package.directory.basename} successfully!'); + return PackageResult.success(); } - // Publish the package to pub with `pub publish`, then git tag the release - // and push the tag to [remoteForTagPush]. - // Returns `true` if publishing and tagging are successful. - Future _publishAndTagPackage( - RepositoryPackage package, { - _RemoteInfo? remoteForTagPush, - }) async { - if (!await _publishPackage(package)) { - return false; - } - if (!await _tagRelease( - package, - remoteForPush: remoteForTagPush, - )) { - return false; - } - print('Published ${package.directory.basename} successfully.'); - return true; + @override + Future completeRun() async { + _pubVersionFinder.httpClient.close(); + await _stdinSubscription?.cancel(); + _stdinSubscription = null; } - // Returns a [_CheckNeedsReleaseResult] that indicates the result. - Future<_CheckNeedsReleaseResult> _checkNeedsRelease({ - required RepositoryPackage package, - required List existingTags, - }) async { + /// Checks whether [package] needs to be released, printing check status and + /// returning one of: + /// - PackageResult.fail if the check could not be completed + /// - PackageResult.skip if no release is necessary + /// - null if releasing should proceed + /// + /// In cases where a non-null result is returned, that should be returned + /// as the final result for the package, without further processing. + Future _checkNeedsRelease(RepositoryPackage package) async { final File pubspecFile = package.pubspecFile; if (!pubspecFile.existsSync()) { - print(''' + logWarning(''' The pubspec file at ${pubspecFile.path} does not exist. Publishing will not happen for ${pubspecFile.parent.basename}. Safe to ignore if the package is deleted in this commit. '''); - return _CheckNeedsReleaseResult.noRelease; + return PackageResult.skip('package deleted'); } final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); @@ -261,17 +229,18 @@ Safe to ignore if the package is deleted in this commit. // Ignore flutter_plugin_tools package when running publishing through flutter_plugin_tools. // TODO(cyanglaz): Make the tool also auto publish flutter_plugin_tools package. // https://github.com/flutter/flutter/issues/85430 - return _CheckNeedsReleaseResult.noRelease; + return PackageResult.skip( + 'publishing flutter_plugin_tools via the tool is not supported'); } if (pubspec.publishTo == 'none') { - return _CheckNeedsReleaseResult.noRelease; + return PackageResult.skip('publish_to: none'); } if (pubspec.version == null) { printError( 'No version found. A package that intentionally has no version should be marked "publish_to: none"'); - return _CheckNeedsReleaseResult.failure; + return PackageResult.fail(['no version']); } // Check if the package named `packageName` with `version` has already @@ -280,49 +249,29 @@ Safe to ignore if the package is deleted in this commit. final PubVersionFinderResponse pubVersionFinderResponse = await _pubVersionFinder.getPackageVersion(packageName: pubspec.name); if (pubVersionFinderResponse.versions.contains(version)) { - final String tagsForPackageWithSameVersion = existingTags.firstWhere( + final String tagsForPackageWithSameVersion = _existingGitTags.firstWhere( (String tag) => tag.split('-v').first == pubspec.name && tag.split('-v').last == version.toString(), orElse: () => ''); - print( - 'The version $version of ${pubspec.name} has already been published'); if (tagsForPackageWithSameVersion.isEmpty) { printError( - 'However, the git release tag for this version (${pubspec.name}-v$version) is not found. Please manually fix the tag then run the command again.'); - return _CheckNeedsReleaseResult.failure; + '${pubspec.name} $version has already been published, however ' + 'the git release tag (${pubspec.name}-v$version) was not found. ' + 'Please manually fix the tag then run the command again.'); + return PackageResult.fail(['published but untagged']); } else { - print('skip.'); - return _CheckNeedsReleaseResult.noRelease; + print('${pubspec.name} $version has already been published.'); + return PackageResult.skip('already published'); } } - return _CheckNeedsReleaseResult.release; - } - - // Publish the package. - // - // Returns `true` if successful, `false` otherwise. - Future _publishPackage(RepositoryPackage package) async { - final bool gitStatusOK = await _checkGitStatus(package); - if (!gitStatusOK) { - return false; - } - final bool publishOK = await _publish(package); - if (!publishOK) { - return false; - } - print('Package published!'); - return true; + return null; } - // Tag the release with -v, and, if [remoteForTagPush] - // is provided, push it to that remote. + // Tag the release with -v, and push it to the remote. // // Return `true` if successful, `false` otherwise. - Future _tagRelease( - RepositoryPackage package, { - _RemoteInfo? remoteForPush, - }) async { + Future _tagRelease(RepositoryPackage package) async { final String tag = _getTag(package); print('Tagging release $tag...'); if (!getBoolArg(_dryRunFlag)) { @@ -335,27 +284,15 @@ Safe to ignore if the package is deleted in this commit. } } - if (remoteForPush == null) { - return true; - } - - print('Pushing tag to ${remoteForPush.name}...'); - return await _pushTagToRemote( + print('Pushing tag to ${_remote.name}...'); + final bool success = await _pushTagToRemote( tag: tag, - remote: remoteForPush, + remote: _remote, ); - } - - Future _finish(bool successful) async { - _pubVersionFinder.httpClient.close(); - await _stdinSubscription?.cancel(); - _stdinSubscription = null; - if (successful) { - print('Done!'); - } else { - printError('Failed, see above for details.'); - throw ToolExit(1); + if (success) { + print('Release tagged!'); } + return success; } Future _checkGitStatus(RepositoryPackage package) async { @@ -394,6 +331,7 @@ Safe to ignore if the package is deleted in this commit. } Future _publish(RepositoryPackage package) async { + print('Publishing...'); final List publishFlags = getStringListArg(_pubFlagsOption); print('Running `pub publish ${publishFlags.join(' ')}` in ' '${package.directory.absolute.path}...\n'); @@ -421,6 +359,8 @@ Safe to ignore if the package is deleted in this commit. printError('Publishing ${package.directory.basename} failed.'); return false; } + + print('Package published!'); return true; } @@ -516,14 +456,3 @@ final String _credentialsPath = () { return p.join(cacheDir, 'credentials.json'); }(); - -enum _CheckNeedsReleaseResult { - // The package needs to be released. - release, - - // The package does not need to be released. - noRelease, - - // There's an error when trying to determine whether the package needs to be released. - failure, -} diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index ae3d768fcc70..2ea4fc753460 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -108,7 +108,8 @@ void main() { '?? /packages/foo/tmp\n\n' 'If the directory should be clean, you can run `git clean -xdf && ' 'git reset --hard HEAD` to wipe all local changes.'), - contains('Failed, see above for details.'), + contains('foo:\n' + ' uncommitted changes'), ])); }); @@ -264,10 +265,13 @@ void main() { isNot(contains('git-push'))); expect( output, - containsAllInOrder([ - '=============== DRY RUN ===============', - 'Running `pub publish ` in ${pluginDir.path}...\n', - 'Done!' + containsAllInOrder([ + contains('=============== DRY RUN ==============='), + contains('Running for foo'), + contains('Running `pub publish ` in ${pluginDir.path}...'), + contains('Tagging release foo-v0.0.1...'), + contains('Pushing tag to upstream...'), + contains('Published foo successfully!'), ])); }); @@ -353,7 +357,8 @@ void main() { expect( output, containsAllInOrder([ - contains('Published foo successfully.'), + contains('Pushing tag to upstream...'), + contains('Published foo successfully!'), ])); }); @@ -376,7 +381,7 @@ void main() { expect( output, containsAllInOrder([ - contains('Published foo successfully.'), + contains('Published foo successfully!'), ])); }); @@ -395,12 +400,12 @@ void main() { isNot(contains('git-push'))); expect( output, - containsAllInOrder([ - '=============== DRY RUN ===============', - 'Running `pub publish ` in ${pluginDir.path}...\n', - 'Tagging release foo-v0.0.1...', - 'Pushing tag to upstream...', - 'Done!' + containsAllInOrder([ + contains('=============== DRY RUN ==============='), + contains('Running `pub publish ` in ${pluginDir.path}...'), + contains('Tagging release foo-v0.0.1...'), + contains('Pushing tag to upstream...'), + contains('Published foo successfully!'), ])); }); @@ -424,7 +429,7 @@ void main() { expect( output, containsAllInOrder([ - contains('Published foo successfully.'), + contains('Published foo successfully!'), ])); }); }); @@ -460,13 +465,11 @@ void main() { expect( output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2/plugin2', - 'Done!' + containsAllInOrder([ + contains('Running `pub publish ` in ${pluginDir1.path}...'), + contains('Running `pub publish ` in ${pluginDir2.path}...'), + contains('plugin1 - \x1B[32mpublished\x1B[0m'), + contains('plugin2/plugin2 - \x1B[32mpublished\x1B[0m'), ])); expect( processRunner.recordedCalls, @@ -522,12 +525,8 @@ void main() { expect( output, containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', 'Running `pub publish ` in ${pluginDir1.path}...\n', 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2/plugin2', - 'Done!' ])); expect( processRunner.recordedCalls, @@ -573,18 +572,16 @@ void main() { expect( output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - '=============== DRY RUN ===============', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Tagging release plugin1-v0.0.1...', - 'Pushing tag to upstream...', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Tagging release plugin2-v0.0.1...', - 'Pushing tag to upstream...', - 'Packages released: plugin1, plugin2/plugin2', - 'Done!' + containsAllInOrder([ + contains('=============== DRY RUN ==============='), + contains('Running `pub publish ` in ${pluginDir1.path}...'), + contains('Tagging release plugin1-v0.0.1...'), + contains('Pushing tag to upstream...'), + contains('Published plugin1 successfully!'), + contains('Running `pub publish ` in ${pluginDir2.path}...'), + contains('Tagging release plugin2-v0.0.1...'), + contains('Pushing tag to upstream...'), + contains('Published plugin2 successfully!'), ])); expect( processRunner.recordedCalls @@ -623,13 +620,11 @@ void main() { ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( output2, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'Running `pub publish ` in ${pluginDir2.path}...\n', - 'Packages released: plugin1, plugin2/plugin2', - 'Done!' + containsAllInOrder([ + contains('Running `pub publish ` in ${pluginDir1.path}...'), + contains('Published plugin1 successfully!'), + contains('Running `pub publish ` in ${pluginDir2.path}...'), + contains('Published plugin2 successfully!'), ])); expect( processRunner.recordedCalls, @@ -642,7 +637,7 @@ void main() { }); test( - 'delete package will not trigger publish but exit the command successfully.', + 'delete package will not trigger publish but exit the command successfully!', () async { mockHttpResponses['plugin1'] = { 'name': 'plugin1', @@ -674,13 +669,13 @@ void main() { ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( output2, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Running `pub publish ` in ${pluginDir1.path}...\n', - 'The pubspec file at ${pluginDir2.childFile('pubspec.yaml').path} does not exist. Publishing will not happen for plugin2.\nSafe to ignore if the package is deleted in this commit.\n', - 'Packages released: plugin1', - 'Done!' + containsAllInOrder([ + contains('Running `pub publish ` in ${pluginDir1.path}...'), + contains('Published plugin1 successfully!'), + contains( + 'The pubspec file at ${pluginDir2.childFile('pubspec.yaml').path} does not exist. Publishing will not happen for plugin2.\nSafe to ignore if the package is deleted in this commit.\n'), + contains('SKIPPING: package deleted'), + contains('skipped (with warning)'), ])); expect( processRunner.recordedCalls, @@ -724,14 +719,11 @@ void main() { expect( output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'The version 0.0.2 of plugin1 has already been published', - 'skip.', - 'The version 0.0.2 of plugin2 has already been published', - 'skip.', - 'Done!' + containsAllInOrder([ + contains('plugin1 0.0.2 has already been published'), + contains('SKIPPING: already published'), + contains('plugin2 0.0.2 has already been published'), + contains('SKIPPING: already published'), ])); expect( @@ -778,12 +770,10 @@ void main() { expect( output, containsAllInOrder([ - contains('The version 0.0.2 of plugin1 has already been published'), - contains( - 'However, the git release tag for this version (plugin1-v0.0.2) is not found.'), - contains('The version 0.0.2 of plugin2 has already been published'), - contains( - 'However, the git release tag for this version (plugin2-v0.0.2) is not found.'), + contains('plugin1 0.0.2 has already been published, ' + 'however the git release tag (plugin1-v0.0.2) was not found.'), + contains('plugin2 0.0.2 has already been published, ' + 'however the git release tag (plugin2-v0.0.2) was not found.'), ])); expect( processRunner.recordedCalls @@ -807,14 +797,7 @@ void main() { final List output = await runCapturingPrint(commandRunner, ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - expect( - output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'No version updates in this commit.', - 'Done!' - ])); + expect(output, containsAllInOrder(['Ran for 0 package(s)'])); expect( processRunner.recordedCalls .map((ProcessCall call) => call.executable), @@ -838,14 +821,13 @@ void main() { expect( output, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Done!' + containsAllInOrder([ + contains( + 'SKIPPING: publishing flutter_plugin_tools via the tool is not supported') ])); expect( output.contains( - 'Running `pub publish ` in ${flutterPluginTools.path}...\n', + 'Running `pub publish ` in ${flutterPluginTools.path}...', ), isFalse); expect( From 6056abeab819d38936da33263511e96d9a0e54a6 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 31 Aug 2021 22:57:43 -0400 Subject: [PATCH 246/364] [flutter_plugin_tools] Fix build-examples for packages (#4248) The build-examples command was filtering what it attempted to build by plugin platform, which means it never does anything for non-plugin packages. flutter/packages has steps that run this command, which suggests it used to work and regressed at some point, but nobody noticed; this will re-enable those builds so that we are getting CI coverage that the examples in flutter/packages build. Mostly fixes https://github.com/flutter/flutter/issues/88435 (needs a flutter/packages tool pin roll to pick this up) --- script/tool/CHANGELOG.md | 4 + .../tool/lib/src/build_examples_command.dart | 66 +++++-- .../src/common/package_looping_command.dart | 9 +- script/tool/lib/src/common/plugin_utils.dart | 137 +++++++-------- script/tool/pubspec.yaml | 2 +- .../test/build_examples_command_test.dart | 162 ++++++++++++++++++ .../common/package_looping_command_test.dart | 58 ++++++- 7 files changed, 355 insertions(+), 83 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 2ef34c184b11..1f1da3551ef8 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0+1 + +- Fixed `build-examples` to work for non-plugin packages. + ## 0.6.0 - Added Android native integration test support to `native-test`. diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index e441f61d5644..56c2f5c7dc87 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -117,39 +117,65 @@ class BuildExamplesCommand extends PackageLoopingCommand { Future runForPackage(RepositoryPackage package) async { final List errors = []; + final bool isPlugin = isFlutterPlugin(package); final Iterable<_PlatformDetails> requestedPlatforms = _platforms.entries .where( (MapEntry entry) => getBoolArg(entry.key)) .map((MapEntry entry) => entry.value); - final Set<_PlatformDetails> buildPlatforms = <_PlatformDetails>{}; - final Set<_PlatformDetails> unsupportedPlatforms = <_PlatformDetails>{}; - for (final _PlatformDetails platform in requestedPlatforms) { - if (pluginSupportsPlatform(platform.pluginPlatform, package, - variant: platform.pluginPlatformVariant)) { - buildPlatforms.add(platform); - } else { - unsupportedPlatforms.add(platform); - } + + // Platform support is checked at the package level for plugins; there is + // no package-level platform information for non-plugin packages. + final Set<_PlatformDetails> buildPlatforms = isPlugin + ? requestedPlatforms + .where((_PlatformDetails platform) => pluginSupportsPlatform( + platform.pluginPlatform, package, + variant: platform.pluginPlatformVariant)) + .toSet() + : requestedPlatforms.toSet(); + + String platformDisplayList(Iterable<_PlatformDetails> platforms) { + return platforms.map((_PlatformDetails p) => p.label).join(', '); } + if (buildPlatforms.isEmpty) { final String unsupported = requestedPlatforms.length == 1 ? '${requestedPlatforms.first.label} is not supported' - : 'None of [${requestedPlatforms.map((_PlatformDetails p) => p.label).join(',')}] are supported'; + : 'None of [${platformDisplayList(requestedPlatforms)}] are supported'; return PackageResult.skip('$unsupported by this plugin'); } - print('Building for: ' - '${buildPlatforms.map((_PlatformDetails platform) => platform.label).join(',')}'); + print('Building for: ${platformDisplayList(buildPlatforms)}'); + + final Set<_PlatformDetails> unsupportedPlatforms = + requestedPlatforms.toSet().difference(buildPlatforms); if (unsupportedPlatforms.isNotEmpty) { + final List skippedPlatforms = unsupportedPlatforms + .map((_PlatformDetails platform) => platform.label) + .toList(); + skippedPlatforms.sort(); print('Skipping unsupported platform(s): ' - '${unsupportedPlatforms.map((_PlatformDetails platform) => platform.label).join(',')}'); + '${skippedPlatforms.join(', ')}'); } print(''); + bool builtSomething = false; for (final RepositoryPackage example in package.getExamples()) { final String packageName = getRelativePosixPath(example.directory, from: packagesDir); for (final _PlatformDetails platform in buildPlatforms) { + // Repo policy is that a plugin must have examples configured for all + // supported platforms. For packages, just log and skip any requested + // platform that a package doesn't have set up. + if (!isPlugin && + !example.directory + .childDirectory(platform.flutterPlatformDirectory) + .existsSync()) { + print('Skipping ${platform.label} for $packageName; not supported.'); + continue; + } + + builtSomething = true; + String buildPlatform = platform.label; if (platform.label.toLowerCase() != platform.flutterBuildType) { buildPlatform += ' (${platform.flutterBuildType})'; @@ -162,6 +188,15 @@ class BuildExamplesCommand extends PackageLoopingCommand { } } + if (!builtSomething) { + if (isPlugin) { + errors.add('No examples found'); + } else { + return PackageResult.skip( + 'No examples found supporting requested platform(s).'); + } + } + return errors.isEmpty ? PackageResult.success() : PackageResult.fail(errors); @@ -235,6 +270,11 @@ class _PlatformDetails { /// The `flutter build` build type. final String flutterBuildType; + /// The Flutter platform directory name. + // In practice, this is the same as the plugin platform key for all platforms. + // If that changes, this can be adjusted. + String get flutterPlatformDirectory => pluginPlatform; + /// Any extra flags to pass to `flutter build`. final List extraBuildFlags; } diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 96dd881bfe00..973ac9995cb8 100644 --- a/script/tool/lib/src/common/package_looping_command.dart +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -237,7 +237,14 @@ abstract class PackageLoopingCommand extends PluginCommand { continue; } - final PackageResult result = await runForPackage(entry.package); + PackageResult result; + try { + result = await runForPackage(entry.package); + } catch (e, stack) { + printError(e.toString()); + printError(stack.toString()); + result = PackageResult.fail(['Unhandled exception']); + } if (result.state == RunState.skipped) { final String message = '${indentation}SKIPPING: ${result.details.first}'; diff --git a/script/tool/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart index 06af675e71ef..6cfe9928d689 100644 --- a/script/tool/lib/src/common/plugin_utils.dart +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -17,6 +17,11 @@ enum PlatformSupport { federated, } +/// Returns true if [package] is a Flutter plugin. +bool isFlutterPlugin(RepositoryPackage package) { + return _readPluginPubspecSection(package) != null; +} + /// Returns true if [package] is a Flutter [platform] plugin. /// /// It checks this by looking for the following pattern in the pubspec: @@ -40,46 +45,43 @@ bool pluginSupportsPlatform( platform == kPlatformMacos || platform == kPlatformWindows || platform == kPlatformLinux); - try { - final YamlMap? platformEntry = - _readPlatformPubspecSectionForPlugin(platform, plugin); - if (platformEntry == null) { + + final YamlMap? platformEntry = + _readPlatformPubspecSectionForPlugin(platform, plugin); + if (platformEntry == null) { + return false; + } + + // If the platform entry is present, then it supports the platform. Check + // for required mode if specified. + if (requiredMode != null) { + final bool federated = platformEntry.containsKey('default_package'); + if (federated != (requiredMode == PlatformSupport.federated)) { return false; } + } - // If the platform entry is present, then it supports the platform. Check - // for required mode if specified. - if (requiredMode != null) { - final bool federated = platformEntry.containsKey('default_package'); - if (federated != (requiredMode == PlatformSupport.federated)) { + // If a variant is specified, check for that variant. + if (variant != null) { + const String variantsKey = 'supportedVariants'; + if (platformEntry.containsKey(variantsKey)) { + if (!(platformEntry['supportedVariants']! as YamlList) + .contains(variant)) { return false; } - } - - // If a variant is specified, check for that variant. - if (variant != null) { - const String variantsKey = 'supportedVariants'; - if (platformEntry.containsKey(variantsKey)) { - if (!(platformEntry['supportedVariants']! as YamlList) - .contains(variant)) { - return false; - } - } else { - // Platforms with variants have a default variant when unspecified for - // backward compatibility. Must match the flutter tool logic. - const Map defaultVariants = { - kPlatformWindows: platformVariantWin32, - }; - if (variant != defaultVariants[platform]) { - return false; - } + } else { + // Platforms with variants have a default variant when unspecified for + // backward compatibility. Must match the flutter tool logic. + const Map defaultVariants = { + kPlatformWindows: platformVariantWin32, + }; + if (variant != defaultVariants[platform]) { + return false; } } - - return true; - } on YamlException { - return false; } + + return true; } /// Returns true if [plugin] includes native code for [platform], as opposed to @@ -89,24 +91,18 @@ bool pluginHasNativeCodeForPlatform(String platform, RepositoryPackage plugin) { // Web plugins are always Dart-only. return false; } - try { - final YamlMap? platformEntry = - _readPlatformPubspecSectionForPlugin(platform, plugin); - if (platformEntry == null) { - return false; - } - // All other platforms currently use pluginClass for indicating the native - // code in the plugin. - final String? pluginClass = platformEntry['pluginClass'] as String?; - // TODO(stuartmorgan): Remove the check for 'none' once none of the plugins - // in the repository use that workaround. See - // https://github.com/flutter/flutter/issues/57497 for context. - return pluginClass != null && pluginClass != 'none'; - } on FileSystemException { - return false; - } on YamlException { + final YamlMap? platformEntry = + _readPlatformPubspecSectionForPlugin(platform, plugin); + if (platformEntry == null) { return false; } + // All other platforms currently use pluginClass for indicating the native + // code in the plugin. + final String? pluginClass = platformEntry['pluginClass'] as String?; + // TODO(stuartmorgan): Remove the check for 'none' once none of the plugins + // in the repository use that workaround. See + // https://github.com/flutter/flutter/issues/57497 for context. + return pluginClass != null && pluginClass != 'none'; } /// Returns the @@ -118,26 +114,33 @@ bool pluginHasNativeCodeForPlatform(String platform, RepositoryPackage plugin) { /// or the pubspec couldn't be read. YamlMap? _readPlatformPubspecSectionForPlugin( String platform, RepositoryPackage plugin) { - try { - final File pubspecFile = plugin.pubspecFile; - final YamlMap pubspecYaml = - loadYaml(pubspecFile.readAsStringSync()) as YamlMap; - final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; - if (flutterSection == null) { - return null; - } - final YamlMap? pluginSection = flutterSection['plugin'] as YamlMap?; - if (pluginSection == null) { - return null; - } - final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; - if (platforms == null) { - return null; - } - return platforms[platform] as YamlMap?; - } on FileSystemException { + final YamlMap? pluginSection = _readPluginPubspecSection(plugin); + if (pluginSection == null) { + return null; + } + final YamlMap? platforms = pluginSection['platforms'] as YamlMap?; + if (platforms == null) { + return null; + } + return platforms[platform] as YamlMap?; +} + +/// Returns the +/// flutter: +/// plugin: +/// platforms: +/// section from [plugin]'s pubspec.yaml, or null if either it is not present, +/// or the pubspec couldn't be read. +YamlMap? _readPluginPubspecSection(RepositoryPackage package) { + final File pubspecFile = package.pubspecFile; + if (!pubspecFile.existsSync()) { return null; - } on YamlException { + } + final YamlMap pubspecYaml = + loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + final YamlMap? flutterSection = pubspecYaml['flutter'] as YamlMap?; + if (flutterSection == null) { return null; } + return flutterSection['plugin'] as YamlMap?; } diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 7c2bb0b3e3c0..adf62ca35a1a 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.6.0 +version: 0.6.0+1 dependencies: args: ^2.1.0 diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index a17107c18e27..d9cbad246d28 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -82,6 +82,35 @@ void main() { ])); }); + test('fails if a plugin has no examples', () async { + createFakePlugin('plugin', packagesDir, + examples: [], + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline) + }); + + processRunner + .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [ + MockProcess(exitCode: 1) // flutter packages get + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['build-examples', '--ios'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The following packages had errors:'), + contains(' plugin:\n' + ' No examples found'), + ])); + }); + test('building for iOS when plugin is not set up for iOS results in no-op', () async { mockPlatform.isMacOS = true; @@ -517,5 +546,138 @@ void main() { pluginExampleDirectory.path), ])); }); + + test('logs skipped platforms', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + }); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--apk', '--ios', '--macos']); + + expect( + output, + containsAllInOrder([ + contains('Skipping unsupported platform(s): iOS, macOS'), + ]), + ); + }); + + group('packages', () { + test('builds when requested platform is supported by example', () async { + final Directory packageDirectory = createFakePackage( + 'package', packagesDir, isFlutter: true, extraFiles: [ + 'example/ios/Runner.xcodeproj/project.pbxproj' + ]); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--ios']); + + expect( + output, + containsAllInOrder([ + contains('Running for package'), + contains('BUILDING package/example for iOS'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + getFlutterCommand(mockPlatform), + const [ + 'build', + 'ios', + '--no-codesign', + ], + packageDirectory.childDirectory('example').path), + ])); + }); + + test('skips non-Flutter examples', () async { + createFakePackage('package', packagesDir, isFlutter: false); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--ios']); + + expect( + output, + containsAllInOrder([ + contains('Running for package'), + contains('No examples found supporting requested platform(s).'), + ]), + ); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skips when there is no example', () async { + createFakePackage('package', packagesDir, + isFlutter: true, examples: []); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--ios']); + + expect( + output, + containsAllInOrder([ + contains('Running for package'), + contains('No examples found supporting requested platform(s).'), + ]), + ); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip when example does not support requested platform', () async { + createFakePackage('package', packagesDir, + isFlutter: true, + extraFiles: ['example/linux/CMakeLists.txt']); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--ios']); + + expect( + output, + containsAllInOrder([ + contains('Running for package'), + contains('Skipping iOS for package/example; not supported.'), + contains('No examples found supporting requested platform(s).'), + ]), + ); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('logs skipped platforms when only some are supported', () async { + final Directory packageDirectory = createFakePackage( + 'package', packagesDir, + isFlutter: true, + extraFiles: ['example/linux/CMakeLists.txt']); + + final List output = await runCapturingPrint( + runner, ['build-examples', '--apk', '--linux']); + + expect( + output, + containsAllInOrder([ + contains('Running for package'), + contains('Building for: Android, Linux'), + contains('Skipping Android for package/example; not supported.'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + getFlutterCommand(mockPlatform), + const ['build', 'linux'], + packageDirectory.childDirectory('example').path), + ])); + }); + }); }); } diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index 721923ae9c6e..7cf03960a74d 100644 --- a/script/tool/test/common/package_looping_command_test.dart +++ b/script/tool/test/common/package_looping_command_test.dart @@ -36,6 +36,8 @@ const String _errorFile = 'errors'; const String _skipFile = 'skip'; // The filename within a package containing warnings to log during runForPackage. const String _warningFile = 'warnings'; +// The filename within a package indicating that it should throw. +const String _throwFile = 'throw'; void main() { late FileSystem fileSystem; @@ -117,7 +119,7 @@ void main() { expect(() => runCommand(command), throwsA(isA())); }); - test('does not stop looping', () async { + test('does not stop looping on error', () async { createFakePackage('package_a', packagesDir); final Directory failingPackage = createFakePlugin('package_b', packagesDir); @@ -141,6 +143,31 @@ void main() { '${_startHeadingColor}Running for package_c...$_endColor', ])); }); + + test('does not stop looping on exceptions', () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + failingPackage.childFile(_throwFile).createSync(); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + '${_startHeadingColor}Running for package_c...$_endColor', + ])); + }); }); group('package iteration', () { @@ -437,6 +464,31 @@ void main() { ])); }); + test('logs unhandled exceptions as errors', () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + failingPackage.childFile(_throwFile).createSync(); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startErrorColor}Exception: Uh-oh$_endColor', + '${_startErrorColor}The following packages had errors:$_endColor', + '$_startErrorColor package_b:\n Unhandled exception$_endColor', + ])); + }); + test('prints run summary on success', () async { final Directory warnPackage1 = createFakePackage('package_a', packagesDir); @@ -657,6 +709,10 @@ class TestPackageLoopingCommand extends PackageLoopingCommand { if (errorFile.existsSync()) { return PackageResult.fail(errorFile.readAsLinesSync()); } + final File throwFile = package.directory.childFile(_throwFile); + if (throwFile.existsSync()) { + throw Exception('Uh-oh'); + } return PackageResult.success(); } From 9ef18bcf6ce105a6668a8dd1fa61a1b7e1346d76 Mon Sep 17 00:00:00 2001 From: Yusuf <82844127+ydag@users.noreply.github.com> Date: Wed, 1 Sep 2021 09:11:03 +0200 Subject: [PATCH 247/364] [image_picker]Android update cache (#4124) --- .../image_picker/image_picker/CHANGELOG.md | 4 ++ .../plugins/imagepicker/ImagePickerCache.java | 25 ++++++++---- .../imagepicker/ImagePickerDelegate.java | 39 +++++++++++-------- .../imagepicker/ImagePickerDelegateTest.java | 34 ++++++++++++++++ .../image_picker/example/lib/main.dart | 1 + .../image_picker/image_picker/pubspec.yaml | 4 +- .../image_picker/test/image_picker_test.dart | 18 +++++++++ .../method_channel_image_picker.dart | 1 - 8 files changed, 100 insertions(+), 26 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index a9255976c526..5dc260993773 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.4 + +* Update `ImagePickerCache` to cache multiple files. + ## 0.8.3+3 * Fix pickImage not returning a value on iOS when dismissing PHPicker sheet by swiping. diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java index 3df0a4108b5c..983dbabf66c3 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerCache.java @@ -10,12 +10,16 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.flutter.plugin.common.MethodCall; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; class ImagePickerCache { static final String MAP_KEY_PATH = "path"; + static final String MAP_KEY_PATH_LIST = "pathList"; static final String MAP_KEY_MAX_WIDTH = "maxWidth"; static final String MAP_KEY_MAX_HEIGHT = "maxHeight"; static final String MAP_KEY_IMAGE_QUALITY = "imageQuality"; @@ -50,7 +54,8 @@ class ImagePickerCache { } void saveTypeWithMethodCallName(String methodCallName) { - if (methodCallName.equals(ImagePickerPlugin.METHOD_CALL_IMAGE)) { + if (methodCallName.equals(ImagePickerPlugin.METHOD_CALL_IMAGE) + | methodCallName.equals(ImagePickerPlugin.METHOD_CALL_MULTI_IMAGE)) { setType("image"); } else if (methodCallName.equals(ImagePickerPlugin.METHOD_CALL_VIDEO)) { setType("video"); @@ -99,11 +104,13 @@ String retrievePendingCameraMediaUriPath() { } void saveResult( - @Nullable String path, @Nullable String errorCode, @Nullable String errorMessage) { + @Nullable ArrayList path, @Nullable String errorCode, @Nullable String errorMessage) { + Set imageSet = new HashSet<>(); + imageSet.addAll(path); SharedPreferences.Editor editor = prefs.edit(); if (path != null) { - editor.putString(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, path); + editor.putStringSet(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, imageSet); } if (errorCode != null) { editor.putString(SHARED_PREFERENCE_ERROR_CODE_KEY, errorCode); @@ -121,12 +128,17 @@ void clear() { Map getCacheMap() { Map resultMap = new HashMap<>(); + ArrayList pathList = new ArrayList<>(); boolean hasData = false; if (prefs.contains(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY)) { - final String imagePathValue = prefs.getString(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, ""); - resultMap.put(MAP_KEY_PATH, imagePathValue); - hasData = true; + final Set imagePathList = + prefs.getStringSet(FLUTTER_IMAGE_PICKER_IMAGE_PATH_KEY, null); + if (imagePathList != null) { + pathList.addAll(imagePathList); + resultMap.put(MAP_KEY_PATH_LIST, pathList); + hasData = true; + } } if (prefs.contains(SHARED_PREFERENCE_ERROR_CODE_KEY)) { @@ -159,7 +171,6 @@ Map getCacheMap() { resultMap.put(MAP_KEY_IMAGE_QUALITY, 100); } } - return resultMap; } } diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index dbd0f70af936..a60c1f173041 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -217,17 +217,21 @@ void saveStateBeforeResult() { void retrieveLostImage(MethodChannel.Result result) { Map resultMap = cache.getCacheMap(); - String path = (String) resultMap.get(cache.MAP_KEY_PATH); - if (path != null) { - Double maxWidth = (Double) resultMap.get(cache.MAP_KEY_MAX_WIDTH); - Double maxHeight = (Double) resultMap.get(cache.MAP_KEY_MAX_HEIGHT); - int imageQuality = - resultMap.get(cache.MAP_KEY_IMAGE_QUALITY) == null - ? 100 - : (int) resultMap.get(cache.MAP_KEY_IMAGE_QUALITY); - - String newPath = imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight, imageQuality); - resultMap.put(cache.MAP_KEY_PATH, newPath); + ArrayList pathList = (ArrayList) resultMap.get(cache.MAP_KEY_PATH_LIST); + ArrayList newPathList = new ArrayList<>(); + if (pathList != null) { + for (String path : pathList) { + Double maxWidth = (Double) resultMap.get(cache.MAP_KEY_MAX_WIDTH); + Double maxHeight = (Double) resultMap.get(cache.MAP_KEY_MAX_HEIGHT); + int imageQuality = + resultMap.get(cache.MAP_KEY_IMAGE_QUALITY) == null + ? 100 + : (int) resultMap.get(cache.MAP_KEY_IMAGE_QUALITY); + + newPathList.add(imageResizer.resizeImageIfNeeded(path, maxWidth, maxHeight, imageQuality)); + } + resultMap.put(cache.MAP_KEY_PATH_LIST, newPathList); + resultMap.put(cache.MAP_KEY_PATH, newPathList.get(newPathList.size() - 1)); } if (resultMap.isEmpty()) { result.success(null); @@ -558,6 +562,7 @@ public void onPathReady(String path) { private void handleMultiImageResult( ArrayList paths, boolean shouldDeleteOriginalIfScaled) { if (methodCall != null) { + ArrayList finalPath = new ArrayList<>(); for (int i = 0; i < paths.size(); i++) { String finalImagePath = getResizedImagePath(paths.get(i)); @@ -567,8 +572,10 @@ private void handleMultiImageResult( && shouldDeleteOriginalIfScaled) { new File(paths.get(i)).delete(); } - paths.set(i, finalImagePath); + finalPath.add(i, finalImagePath); } + finishWithListSuccess(finalPath); + } else { finishWithListSuccess(paths); } } @@ -615,7 +622,9 @@ private boolean setPendingMethodCallAndResult( private void finishWithSuccess(String imagePath) { if (pendingResult == null) { - cache.saveResult(imagePath, null, null); + ArrayList pathList = new ArrayList<>(); + pathList.add(imagePath); + cache.saveResult(pathList, null, null); return; } pendingResult.success(imagePath); @@ -624,9 +633,7 @@ private void finishWithSuccess(String imagePath) { private void finishWithListSuccess(ArrayList imagePaths) { if (pendingResult == null) { - for (String imagePath : imagePaths) { - cache.saveResult(imagePath, null, null); - } + cache.saveResult(imagePaths, null, null); return; } pendingResult.success(imagePaths); diff --git a/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java index ebd58d05fee4..d2ee7b0b7d61 100644 --- a/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -5,10 +5,12 @@ package io.flutter.plugins.imagepicker; import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -26,9 +28,13 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -368,6 +374,34 @@ public void onActivityResult_WhenImageTakenWithCamera_AndNoResizeNeeded_Finishes verifyNoMoreInteractions(mockResult); } + @Test + public void + retrieveLostImage_ShouldBeAbleToReturnLastItemFromResultMapWhenSingleFileIsRecovered() { + Map resultMap = new HashMap<>(); + ArrayList pathList = new ArrayList<>(); + pathList.add("/example/first_item"); + pathList.add("/example/last_item"); + resultMap.put("pathList", pathList); + + when(mockImageResizer.resizeImageIfNeeded(pathList.get(0), null, null, 100)) + .thenReturn(pathList.get(0)); + when(mockImageResizer.resizeImageIfNeeded(pathList.get(1), null, null, 100)) + .thenReturn(pathList.get(1)); + when(cache.getCacheMap()).thenReturn(resultMap); + + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + ImagePickerDelegate mockDelegate = createDelegate(); + + ArgumentCaptor> valueCapture = ArgumentCaptor.forClass(Map.class); + + doNothing().when(mockResult).success(valueCapture.capture()); + + mockDelegate.retrieveLostImage(mockResult); + + assertEquals("/example/last_item", valueCapture.getValue().get("path")); + } + private ImagePickerDelegate createDelegate() { return new ImagePickerDelegate( mockActivity, diff --git a/packages/image_picker/image_picker/example/lib/main.dart b/packages/image_picker/image_picker/example/lib/main.dart index 2d5fd9aee4a7..0f5ba76db6df 100755 --- a/packages/image_picker/image_picker/example/lib/main.dart +++ b/packages/image_picker/image_picker/example/lib/main.dart @@ -226,6 +226,7 @@ class _MyHomePageState extends State { isVideo = false; setState(() { _imageFile = response.file; + _imageFileList = response.files; }); } } else { diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 4becca930261..3bbcfe99882e 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.3+3 +version: 0.8.4 environment: sdk: ">=2.12.0 <3.0.0" @@ -25,7 +25,7 @@ dependencies: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 image_picker_for_web: ^2.1.0 - image_picker_platform_interface: ^2.2.0 + image_picker_platform_interface: ^2.3.0 dev_dependencies: flutter_test: diff --git a/packages/image_picker/image_picker/test/image_picker_test.dart b/packages/image_picker/image_picker/test/image_picker_test.dart index 960dfe6917ea..10bc64082aca 100644 --- a/packages/image_picker/image_picker/test/image_picker_test.dart +++ b/packages/image_picker/image_picker/test/image_picker_test.dart @@ -315,6 +315,24 @@ void main() { expect(response.file!.path, '/example/path'); }); + test('retrieveLostData should successfully retrieve multiple files', + () async { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return { + 'type': 'image', + 'path': '/example/path1', + 'pathList': ['/example/path0', '/example/path1'], + }; + }); + + final LostDataResponse response = await picker.retrieveLostData(); + expect(response.type, RetrieveType.image); + expect(response.file, isNotNull); + expect(response.file!.path, '/example/path1'); + expect(response.files!.first.path, '/example/path0'); + expect(response.files!.length, 2); + }); + test('retrieveLostData get error response', () async { channel.setMockMethodCallHandler((MethodCall methodCall) async { return { diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart index 292cb814ddeb..b02284e957fa 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart @@ -259,7 +259,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { final pathList = result['pathList']; if (pathList != null) { pickedFileList = []; - // In this case, multiRetrieve is invoked. for (String path in pathList) { pickedFileList.add(XFile(path)); } From df75b01c5a2d860fa22886032dda12c5dd5ce27c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 1 Sep 2021 14:18:22 -0400 Subject: [PATCH 248/364] [flutter_plugin_tools] Add Linux support to native-test (#4294) - Adds a minimal unit test to url_launcher_linux as a proof of concept. This uses almost exactly the same CMake structure as the Windows version that was added recently. - Adds Linux support for unit tests to `native-test`, sharing almost all of the existing Windows codepath. - Fixes the fact that it it was running the debug version of the unit tests, but `build-examples` only builds release. (On other platforms we run debug unit tests, but on those platforms the test command internally builds the requested unit tests, so the mismatch doesn't matter.) - Enables the new test in CI. Also opportunistically fixes some documentation in `native_test_command.dart` that wasn't updated as more platform support was added. Linux portion of https://github.com/flutter/flutter/issues/82445 --- .cirrus.yml | 2 + .../example/linux/CMakeLists.txt | 3 + .../example/linux/flutter/CMakeLists.txt | 3 +- .../url_launcher_linux/linux/CMakeLists.txt | 47 +++++- .../linux/test/url_launcher_linux_test.cc | 57 +++++++ .../linux/url_launcher_plugin.cc | 4 +- .../linux/url_launcher_plugin_private.h | 14 ++ script/tool/CHANGELOG.md | 4 + script/tool/lib/src/native_test_command.dart | 37 ++++- .../tool/test/native_test_command_test.dart | 156 +++++++++++++++++- 10 files changed, 312 insertions(+), 15 deletions(-) create mode 100644 packages/url_launcher/url_launcher_linux/linux/test/url_launcher_linux_test.cc create mode 100644 packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin_private.h diff --git a/.cirrus.yml b/.cirrus.yml index d830a2a15913..10d668d8d1d7 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -127,6 +127,8 @@ task: build_script: - flutter config --enable-linux-desktop - ./script/tool_runner.sh build-examples --linux + native_test_script: + - ./script/tool_runner.sh native-test --linux --no-integration drive_script: - xvfb-run ./script/tool_runner.sh drive-examples --linux diff --git a/packages/url_launcher/url_launcher_linux/example/linux/CMakeLists.txt b/packages/url_launcher/url_launcher_linux/example/linux/CMakeLists.txt index 0236a8806654..1758aac03b0d 100644 --- a/packages/url_launcher/url_launcher_linux/example/linux/CMakeLists.txt +++ b/packages/url_launcher/url_launcher_linux/example/linux/CMakeLists.txt @@ -43,6 +43,9 @@ target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) add_dependencies(${BINARY_NAME} flutter_assemble) +# Enable the test target. +set(include_url_launcher_linux_tests TRUE) + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) diff --git a/packages/url_launcher/url_launcher_linux/example/linux/flutter/CMakeLists.txt b/packages/url_launcher/url_launcher_linux/example/linux/flutter/CMakeLists.txt index 94f43ff7fa6a..33fd5801e713 100644 --- a/packages/url_launcher/url_launcher_linux/example/linux/flutter/CMakeLists.txt +++ b/packages/url_launcher/url_launcher_linux/example/linux/flutter/CMakeLists.txt @@ -78,7 +78,8 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - linux-x64 ${CMAKE_BUILD_TYPE} + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" diff --git a/packages/url_launcher/url_launcher_linux/linux/CMakeLists.txt b/packages/url_launcher/url_launcher_linux/linux/CMakeLists.txt index 1403d0cbc9e4..b3f4a22b053d 100644 --- a/packages/url_launcher/url_launcher_linux/linux/CMakeLists.txt +++ b/packages/url_launcher/url_launcher_linux/linux/CMakeLists.txt @@ -4,9 +4,13 @@ project(${PROJECT_NAME} LANGUAGES CXX) set(PLUGIN_NAME "${PROJECT_NAME}_plugin") -add_library(${PLUGIN_NAME} SHARED +list(APPEND PLUGIN_SOURCES "url_launcher_plugin.cc" ) + +add_library(${PLUGIN_NAME} SHARED + ${PLUGIN_SOURCES} +) apply_standard_settings(${PLUGIN_NAME}) set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) @@ -15,3 +19,44 @@ target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + + +# === Tests === + +if (${include_${PROJECT_NAME}_tests}) +if(${CMAKE_VERSION} VERSION_LESS "3.11.0") +message("Unit tests require CMake 3.11.0 or later") +else() +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() +# TODO(stuartmorgan): Consider using a single shared, pre-checked-in googletest +# instance rather than downloading for each plugin. This approach makes sense +# for a template, but not for a monorepo with many plugins. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + +FetchContent_MakeAvailable(googletest) + +# The plugin's exported API is not very useful for unit testing, so build the +# sources directly into the test binary rather than using the shared library. +add_executable(${TEST_RUNNER} + test/url_launcher_linux_test.cc + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter) +target_link_libraries(${TEST_RUNNER} PRIVATE PkgConfig::GTK) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) + +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) +endif() # CMake version check +endif() # include_${PROJECT_NAME}_tests diff --git a/packages/url_launcher/url_launcher_linux/linux/test/url_launcher_linux_test.cc b/packages/url_launcher/url_launcher_linux/linux/test/url_launcher_linux_test.cc new file mode 100644 index 000000000000..e655638c4ed7 --- /dev/null +++ b/packages/url_launcher/url_launcher_linux/linux/test/url_launcher_linux_test.cc @@ -0,0 +1,57 @@ +// Copyright 2013 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. +#include +#include +#include + +#include +#include + +#include "include/url_launcher_linux/url_launcher_plugin.h" +#include "url_launcher_plugin_private.h" + +namespace url_launcher_plugin { +namespace test { + +TEST(UrlLauncherPlugin, CanLaunchSuccess) { + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "url", + fl_value_new_string("https://flutter.dev")); + FlMethodResponse* response = can_launch(nullptr, args); + ASSERT_NE(response, nullptr); + ASSERT_TRUE(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + g_autoptr(FlValue) expected = fl_value_new_bool(true); + EXPECT_TRUE(fl_value_equal(fl_method_success_response_get_result( + FL_METHOD_SUCCESS_RESPONSE(response)), + expected)); +} + +TEST(UrlLauncherPlugin, CanLaunchFailureUnhandled) { + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "url", fl_value_new_string("madeup:scheme")); + FlMethodResponse* response = can_launch(nullptr, args); + ASSERT_NE(response, nullptr); + ASSERT_TRUE(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + g_autoptr(FlValue) expected = fl_value_new_bool(false); + EXPECT_TRUE(fl_value_equal(fl_method_success_response_get_result( + FL_METHOD_SUCCESS_RESPONSE(response)), + expected)); +} + +// For consistency with the established mobile implementations, +// an invalid URL should return false, not an error. +TEST(UrlLauncherPlugin, CanLaunchFailureInvalidUrl) { + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "url", fl_value_new_string("")); + FlMethodResponse* response = can_launch(nullptr, args); + ASSERT_NE(response, nullptr); + ASSERT_TRUE(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + g_autoptr(FlValue) expected = fl_value_new_bool(false); + EXPECT_TRUE(fl_value_equal(fl_method_success_response_get_result( + FL_METHOD_SUCCESS_RESPONSE(response)), + expected)); +} + +} // namespace test +} // namespace url_launcher_plugin diff --git a/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin.cc b/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin.cc index 6e10607dd14e..d3f454ee7198 100644 --- a/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin.cc +++ b/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin.cc @@ -9,6 +9,8 @@ #include +#include "url_launcher_plugin_private.h" + // See url_launcher_channel.dart for documentation. const char kChannelName[] = "plugins.flutter.io/url_launcher"; const char kBadArgumentsError[] = "Bad Arguments"; @@ -44,7 +46,7 @@ static gchar* get_url(FlValue* args, GError** error) { } // Called to check if a URL can be launched. -static FlMethodResponse* can_launch(FlUrlLauncherPlugin* self, FlValue* args) { +FlMethodResponse* can_launch(FlUrlLauncherPlugin* self, FlValue* args) { g_autoptr(GError) error = nullptr; g_autofree gchar* url = get_url(args, &error); if (url == nullptr) { diff --git a/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin_private.h b/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin_private.h new file mode 100644 index 000000000000..cde5242a8f47 --- /dev/null +++ b/packages/url_launcher/url_launcher_linux/linux/url_launcher_plugin_private.h @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +#include + +#include "include/url_launcher_linux/url_launcher_plugin.h" + +// TODO(stuartmorgan): Remove this private header and change the below back to +// a static function once https://github.com/flutter/flutter/issues/88724 +// is fixed, and test through the public API instead. + +// Handles the canLaunch method call. +FlMethodResponse* can_launch(FlUrlLauncherPlugin* self, FlValue* args); diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 1f1da3551ef8..9b6bbb1f71cc 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +- `native-test` now supports `--linux` for unit tests. + ## 0.6.0+1 - Fixed `build-examples` to work for non-plugin packages. diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 5120ad10b872..e50878db7906 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -21,7 +21,9 @@ const String _iosDestinationFlag = 'ios-destination'; const int _exitNoIosSimulators = 3; /// The command to run native tests for plugins: -/// - iOS and macOS: XCTests (XCUnitTest and XCUITest) in plugins. +/// - iOS and macOS: XCTests (XCUnitTest and XCUITest) +/// - Android: JUnit tests +/// - Windows and Linux: GoogleTest tests class NativeTestCommand extends PackageLoopingCommand { /// Creates an instance of the test command. NativeTestCommand( @@ -39,6 +41,7 @@ class NativeTestCommand extends PackageLoopingCommand { ); argParser.addFlag(kPlatformAndroid, help: 'Runs Android tests'); argParser.addFlag(kPlatformIos, help: 'Runs iOS tests'); + argParser.addFlag(kPlatformLinux, help: 'Runs Linux tests'); argParser.addFlag(kPlatformMacos, help: 'Runs macOS tests'); argParser.addFlag(kPlatformWindows, help: 'Runs Windows tests'); @@ -63,9 +66,11 @@ class NativeTestCommand extends PackageLoopingCommand { Runs native unit tests and native integration tests. Currently supported platforms: -- Android (unit tests only) +- Android - iOS: requires 'xcrun' to be in your path. +- Linux (unit tests only) - macOS: requires 'xcrun' to be in your path. +- Windows (unit tests only) The example app(s) must be built for all targeted platforms before running this command. @@ -80,6 +85,7 @@ this command. _platforms = { kPlatformAndroid: _PlatformDetails('Android', _testAndroid), kPlatformIos: _PlatformDetails('iOS', _testIos), + kPlatformLinux: _PlatformDetails('Linux', _testLinux), kPlatformMacos: _PlatformDetails('macOS', _testMacOS), kPlatformWindows: _PlatformDetails('Windows', _testWindows), }; @@ -103,6 +109,11 @@ this command. 'See https://github.com/flutter/flutter/issues/70233.'); } + if (getBoolArg(kPlatformLinux) && getBoolArg(_integrationTestFlag)) { + logWarning('This command currently only supports unit tests for Linux. ' + 'See https://github.com/flutter/flutter/issues/70235.'); + } + // iOS-specific run-level state. if (_requestedPlatforms.contains('ios')) { String destination = getStringArg(_iosDestinationFlag); @@ -418,6 +429,21 @@ this command. buildDirectoryName: 'windows', isTestBinary: isTestBinary); } + Future<_PlatformResult> _testLinux( + RepositoryPackage plugin, _TestMode mode) async { + if (mode.integrationOnly) { + return _PlatformResult(RunState.skipped); + } + + bool isTestBinary(File file) { + return file.basename.endsWith('_test') || + file.basename.endsWith('_tests'); + } + + return _runGoogleTestTests(plugin, + buildDirectoryName: 'linux', isTestBinary: isTestBinary); + } + /// Finds every file in the [buildDirectoryName] subdirectory of [plugin]'s /// build directory for which [isTestBinary] is true, and runs all of them, /// returning the overall result. @@ -442,10 +468,11 @@ this command. .whereType() .where(isTestBinary) .where((File file) { - // Only run the debug build of the unit tests, to avoid running the - // same tests multiple times. + // Only run the release build of the unit tests, to avoid running the + // same tests multiple times. Release is used rather than debug since + // `build-examples` builds release versions. final List components = path.split(file.path); - return components.contains('debug') || components.contains('Debug'); + return components.contains('release') || components.contains('Release'); })); } diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index 3613a808d9b8..d1ab11f6e50d 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -736,6 +736,147 @@ void main() { }); }); + group('Linux', () { + test('runs unit tests', () async { + const String testBinaryRelativePath = + 'build/linux/foo/release/bar/plugin_test'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/$testBinaryRelativePath' + ], platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + }); + + final File testBinary = childFileWithSubcomponents(pluginDirectory, + ['example', ...testBinaryRelativePath.split('/')]); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--linux', + '--no-integration', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running plugin_test...'), + contains('No issues found!'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall(testBinary.path, const [], null), + ])); + }); + + test('only runs release unit tests', () async { + const String debugTestBinaryRelativePath = + 'build/linux/foo/debug/bar/plugin_test'; + const String releaseTestBinaryRelativePath = + 'build/linux/foo/release/bar/plugin_test'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/$debugTestBinaryRelativePath', + 'example/$releaseTestBinaryRelativePath' + ], platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + }); + + final File releaseTestBinary = childFileWithSubcomponents( + pluginDirectory, + ['example', ...releaseTestBinaryRelativePath.split('/')]); + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--linux', + '--no-integration', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running plugin_test...'), + contains('No issues found!'), + ]), + ); + + // Only the release version should be run. + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall(releaseTestBinary.path, const [], null), + ])); + }); + + test('fails if there are no unit tests', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + }); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--linux', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No test binaries found.'), + ]), + ); + + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('fails if a unit test fails', () async { + const String testBinaryRelativePath = + 'build/linux/foo/release/bar/plugin_test'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/$testBinaryRelativePath' + ], platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + }); + + final File testBinary = childFileWithSubcomponents(pluginDirectory, + ['example', ...testBinaryRelativePath.split('/')]); + + processRunner.mockProcessesForExecutable[testBinary.path] = + [MockProcess(exitCode: 1)]; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--linux', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running plugin_test...'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall(testBinary.path, const [], null), + ])); + }); + }); + // Tests behaviors of implementation that is shared between iOS and macOS. group('iOS/macOS', () { test('fails if xcrun fails', () async { @@ -1352,7 +1493,7 @@ void main() { group('Windows', () { test('runs unit tests', () async { const String testBinaryRelativePath = - 'build/windows/foo/Debug/bar/plugin_test.exe'; + 'build/windows/foo/Release/bar/plugin_test.exe'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$testBinaryRelativePath' @@ -1384,7 +1525,7 @@ void main() { ])); }); - test('only runs debug unit tests', () async { + test('only runs release unit tests', () async { const String debugTestBinaryRelativePath = 'build/windows/foo/Debug/bar/plugin_test.exe'; const String releaseTestBinaryRelativePath = @@ -1397,8 +1538,9 @@ void main() { kPlatformWindows: const PlatformDetails(PlatformSupport.inline), }); - final File debugTestBinary = childFileWithSubcomponents(pluginDirectory, - ['example', ...debugTestBinaryRelativePath.split('/')]); + final File releaseTestBinary = childFileWithSubcomponents( + pluginDirectory, + ['example', ...releaseTestBinaryRelativePath.split('/')]); final List output = await runCapturingPrint(runner, [ 'native-test', @@ -1414,11 +1556,11 @@ void main() { ]), ); - // Only the debug version should be run. + // Only the release version should be run. expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(debugTestBinary.path, const [], null), + ProcessCall(releaseTestBinary.path, const [], null), ])); }); @@ -1450,7 +1592,7 @@ void main() { test('fails if a unit test fails', () async { const String testBinaryRelativePath = - 'build/windows/foo/Debug/bar/plugin_test.exe'; + 'build/windows/foo/Release/bar/plugin_test.exe'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$testBinaryRelativePath' From bafc4ee36021ab80113c6955f3f4271950ec63b2 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Wed, 1 Sep 2021 13:11:03 -0700 Subject: [PATCH 249/364] Add a way to opt a file out of Dart formatting (#4292) --- script/tool/CHANGELOG.md | 4 ++- script/tool/lib/src/format_command.dart | 20 ++++++++++++ script/tool/pubspec.yaml | 2 +- script/tool/test/format_command_test.dart | 37 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 9b6bbb1f71cc..098e57a8c62d 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,6 +1,8 @@ -## NEXT +## 0.7.0 - `native-test` now supports `--linux` for unit tests. +- Formatting now skips Dart files that contain a line that exactly + matches the string `// This file is hand-formatted.`. ## 0.6.0+1 diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart index d09a94b1aefe..f24a99436c87 100644 --- a/script/tool/lib/src/format_command.dart +++ b/script/tool/lib/src/format_command.dart @@ -206,7 +206,27 @@ class FormatCommand extends PluginCommand { final String fromPath = relativeTo.path; + // Dart files are allowed to have a pragma to disable auto-formatting. This + // was added because Hixie hurts when dealing with what dartfmt does to + // artisanally-formatted Dart, while Stuart gets really frustrated when + // dealing with PRs from newer contributors who don't know how to make Dart + // readable. After much discussion, it was decided that files in the plugins + // and packages repos that really benefit from hand-formatting (e.g. files + // with large blobs of hex literals) could be opted-out of the requirement + // that they be autoformatted, so long as the code's owner was willing to + // bear the cost of this during code reviews. + // In the event that code ownership moves to someone who does not hold the + // same views as the original owner, the pragma can be removed and the file + // auto-formatted. + const String handFormattedExtension = '.dart'; + const String handFormattedPragma = '// This file is hand-formatted.'; + return files + .where((File file) { + // See comment above near [handFormattedPragma]. + return path.extension(file.path) != handFormattedExtension || + !file.readAsLinesSync().contains(handFormattedPragma); + }) .map((File file) => path.relative(file.path, from: fromPath)) .where((String path) => // Ignore files in build/ directories (e.g., headers of frameworks) diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index adf62ca35a1a..2569e0ede870 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.6.0+1 +version: 0.7.0 dependencies: args: ^2.1.0 diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index e2bf1e3e6e8e..d278bb2940b8 100644 --- a/script/tool/test/format_command_test.dart +++ b/script/tool/test/format_command_test.dart @@ -8,6 +8,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/file_utils.dart'; import 'package:flutter_plugin_tools/src/format_command.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -106,6 +107,42 @@ void main() { ])); }); + test('does not format .dart files with pragma', () async { + const List formattedFiles = [ + 'lib/a.dart', + 'lib/src/b.dart', + 'lib/src/c.dart', + ]; + const String unformattedFile = 'lib/src/d.dart'; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: [ + ...formattedFiles, + unformattedFile, + ], + ); + + final p.Context posixContext = p.posix; + childFileWithSubcomponents(pluginDir, posixContext.split(unformattedFile)) + .writeAsStringSync( + '// copyright bla bla\n// This file is hand-formatted.\ncode...'); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + getFlutterCommand(mockPlatform), + [ + 'format', + ..._getPackagesDirRelativePaths(pluginDir, formattedFiles) + ], + packagesDir.path), + ])); + }); + test('fails if flutter format fails', () async { const List files = [ 'lib/a.dart', From 5d1ed48dfc8e0d6a9fa9afe137e18071c190e1b8 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 2 Sep 2021 13:11:04 +0200 Subject: [PATCH 250/364] Ensure setExposureOffset returns new value on Android (#4301) --- packages/camera/camera/CHANGELOG.md | 4 ++++ .../src/main/java/io/flutter/plugins/camera/Camera.java | 2 +- .../test/java/io/flutter/plugins/camera/CameraTest.java | 4 +++- packages/camera/camera/example/lib/main.dart | 7 +++++++ packages/camera/camera/pubspec.yaml | 2 +- 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 5a3a1bf251d7..b141fab62595 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.2+2 + +* Ensure that setting the exposure offset returns the new offset value on Android. + ## 0.9.2+1 * Fixed camera controller throwing an exception when being replaced in the preview widget. diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index c036c1c7e9d3..4601e7d34d69 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -906,7 +906,7 @@ public void setExposureOffset(@NonNull final Result result, double offset) { exposureOffsetFeature.updateBuilder(previewRequestBuilder); refreshPreviewCaptureSession( - () -> result.success(null), + () -> result.success(exposureOffsetFeature.getValue()), (code, message) -> result.error("setExposureOffsetFailed", "Could not set exposure offset.", null)); } diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java index 5431df0df636..fbed28bc11fc 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -687,11 +687,13 @@ public void setExposureOffset_shouldUpdateExposureOffsetFeature() { mockCameraFeatureFactory.createExposureOffsetFeature(mockCameraProperties); MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + when(mockExposureOffsetFeature.getValue()).thenReturn(1.0); + camera.setExposureOffset(mockResult, 1.0); verify(mockExposureOffsetFeature, times(1)).setValue(1.0); verify(mockResult, never()).error(any(), any(), any()); - verify(mockResult, times(1)).success(null); + verify(mockResult, times(1)).success(1.0); } @Test diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index a8067001aae5..c0e90eefa3ab 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -399,6 +399,13 @@ class _CameraExampleHomeState extends State onSetExposureModeButtonPressed(ExposureMode.locked) : null, ), + TextButton( + child: Text('RESET OFFSET'), + style: styleLocked, + onPressed: controller != null + ? () => controller!.setExposureOffset(0.0) + : null, + ), ], ), Center( diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 400b8c03f44a..582a830ebb4c 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.2+1 +version: 0.9.2+2 environment: sdk: ">=2.12.0 <3.0.0" From 6f037b43ff0feaf7b52299b373cb02960e751cae Mon Sep 17 00:00:00 2001 From: Kyle Finlinson <5882840+KyleFin@users.noreply.github.com> Date: Thu, 2 Sep 2021 10:46:03 -0600 Subject: [PATCH 251/364] [video_player] Ensure seekTo is not called before video player is initialized. (#4300) --- .../video_player/video_player/CHANGELOG.md | 3 ++- .../video_player/lib/video_player.dart | 12 +++++----- .../video_player/video_player/pubspec.yaml | 2 +- .../video_player/test/video_player_test.dart | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index f07bb5f66f8c..9cb642a4db56 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.1.15 +* Ensured seekTo isn't called before video player is initialized. Fixes [#89259](https://github.com/flutter/flutter/issues/89259). * Updated Android lint settings. ## 2.1.14 diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 772409258ac4..b4c4b2b2a311 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -415,14 +415,14 @@ class VideoPlayerController extends ValueNotifier { } Future _applyLooping() async { - if (!value.isInitialized || _isDisposed) { + if (_isDisposedOrNotInitialized) { return; } await _videoPlayerPlatform.setLooping(_textureId, value.isLooping); } Future _applyPlayPause() async { - if (!value.isInitialized || _isDisposed) { + if (_isDisposedOrNotInitialized) { return; } if (value.isPlaying) { @@ -455,14 +455,14 @@ class VideoPlayerController extends ValueNotifier { } Future _applyVolume() async { - if (!value.isInitialized || _isDisposed) { + if (_isDisposedOrNotInitialized) { return; } await _videoPlayerPlatform.setVolume(_textureId, value.volume); } Future _applyPlaybackSpeed() async { - if (!value.isInitialized || _isDisposed) { + if (_isDisposedOrNotInitialized) { return; } @@ -491,7 +491,7 @@ class VideoPlayerController extends ValueNotifier { /// If [moment] is outside of the video's full range it will be automatically /// and silently clamped. Future seekTo(Duration position) async { - if (_isDisposed) { + if (_isDisposedOrNotInitialized) { return; } if (position > value.duration) { @@ -572,6 +572,8 @@ class VideoPlayerController extends ValueNotifier { value = value.copyWith(position: position); value = value.copyWith(caption: _getCaptionAt(position)); } + + bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized; } class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver { diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 0d0cdb1cb436..7f6f608687cc 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.14 +version: 2.1.15 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index b5bfad605620..ad536f840c1d 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -318,6 +318,17 @@ void main() { expect(fakeVideoPlayerPlatform.calls.last, 'setPlaybackSpeed'); }); + test('play before initialized does not call platform', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + expect(controller.value.isInitialized, isFalse); + + await controller.play(); + + expect(fakeVideoPlayerPlatform.calls, isEmpty); + }); + test('play restarts from beginning if video is at end', () async { final VideoPlayerController controller = VideoPlayerController.network( 'https://127.0.0.1', @@ -373,6 +384,17 @@ void main() { expect(await controller.position, const Duration(milliseconds: 500)); }); + test('before initialized does not call platform', () async { + final VideoPlayerController controller = VideoPlayerController.network( + 'https://127.0.0.1', + ); + expect(controller.value.isInitialized, isFalse); + + await controller.seekTo(const Duration(milliseconds: 500)); + + expect(fakeVideoPlayerPlatform.calls, isEmpty); + }); + test('clamps values that are too high or low', () async { final VideoPlayerController controller = VideoPlayerController.network( 'https://127.0.0.1', From 887ba2d8f1350bd4f9c2e1082077baa2b681a4c3 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 2 Sep 2021 13:04:25 -0400 Subject: [PATCH 252/364] Add scripts for Windows LUCI recipe (#4303) See https://github.com/flutter/flutter/issues/89373 for context. This sets up the script and config file to replicate the current plugins.py recipe's behavior using the planned generic recipe. These are currently unused, but are being landed first so that the generic recipe has something to test against. Part of https://github.com/flutter/flutter/issues/89373 --- .ci.yaml | 2 ++ .ci/scripts/build_examples_win32.sh | 7 +++++++ .ci/scripts/drive_examples_win32.sh | 7 +++++++ .ci/scripts/prepare_tool.sh | 10 ++++++++++ .ci/targets/windows_build_and_platform_tests.yaml | 8 ++++++++ 5 files changed, 34 insertions(+) create mode 100644 .ci/scripts/build_examples_win32.sh create mode 100644 .ci/scripts/drive_examples_win32.sh create mode 100644 .ci/scripts/prepare_tool.sh create mode 100644 .ci/targets/windows_build_and_platform_tests.yaml diff --git a/.ci.yaml b/.ci.yaml index 6b5c385aa98e..460badfbdf12 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -39,6 +39,7 @@ targets: recipe: plugins/plugins timeout: 30 properties: + target_file: windows_build_and_platform_tests.yaml dependencies: > [ {"dependency": "vs_build"} @@ -49,6 +50,7 @@ targets: recipe: plugins/plugins timeout: 30 properties: + target_file: windows_build_and_platform_tests.yaml channel: stable dependencies: > [ diff --git a/.ci/scripts/build_examples_win32.sh b/.ci/scripts/build_examples_win32.sh new file mode 100644 index 000000000000..8c090f4b78d2 --- /dev/null +++ b/.ci/scripts/build_examples_win32.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 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. + +dart ./script/tool/bin/flutter_plugin_tools.dart build-examples --windows \ + --packages-for-branch diff --git a/.ci/scripts/drive_examples_win32.sh b/.ci/scripts/drive_examples_win32.sh new file mode 100644 index 000000000000..63abc06bec5a --- /dev/null +++ b/.ci/scripts/drive_examples_win32.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 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. + +dart ./script/tool/bin/flutter_plugin_tools.dart drive-examples --windows \ + --packages-for-branch diff --git a/.ci/scripts/prepare_tool.sh b/.ci/scripts/prepare_tool.sh new file mode 100644 index 000000000000..1095e2189a36 --- /dev/null +++ b/.ci/scripts/prepare_tool.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Copyright 2013 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. + +# To set FETCH_HEAD for "git merge-base" to work +git fetch origin master + +cd script/tool +dart pub get diff --git a/.ci/targets/windows_build_and_platform_tests.yaml b/.ci/targets/windows_build_and_platform_tests.yaml new file mode 100644 index 000000000000..cba120073310 --- /dev/null +++ b/.ci/targets/windows_build_and_platform_tests.yaml @@ -0,0 +1,8 @@ + +tasks: + - name: "prepare tool" + script: .ci/scripts/prepare_tool.sh + - name: "build examples" + script: .ci/scripts/build_examples_win32.sh + - name: "drive examples" + script: .ci/scripts/drive_examples_win32.sh From 2aa9a0947c6172e02d745631d2de6c8a08743554 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Thu, 2 Sep 2021 15:36:03 -0700 Subject: [PATCH 253/364] [ci.yaml] Add builders to recipes cq (#4306) --- .ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.ci.yaml b/.ci.yaml index 460badfbdf12..ebedd203b3ca 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -39,6 +39,7 @@ targets: recipe: plugins/plugins timeout: 30 properties: + add_recipes_cq: "true" target_file: windows_build_and_platform_tests.yaml dependencies: > [ @@ -50,6 +51,7 @@ targets: recipe: plugins/plugins timeout: 30 properties: + add_recipes_cq: "true" target_file: windows_build_and_platform_tests.yaml channel: stable dependencies: > From 08573317673f7cf3257199871b0f4a0db50622af Mon Sep 17 00:00:00 2001 From: ThetaSinner Date: Fri, 3 Sep 2021 05:46:02 +0100 Subject: [PATCH 254/364] adds option to re-authenticate on google silent sign in (#4251) --- .../google_sign_in/google_sign_in/CHANGELOG.md | 4 +++- .../google_sign_in/lib/google_sign_in.dart | 9 ++++++--- .../google_sign_in/google_sign_in/pubspec.yaml | 2 +- .../google_sign_in/test/google_sign_in_test.dart | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 8ac07ae1793b..1f0be2e237b2 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,4 +1,6 @@ -## NEXT +## 5.1.0 + +* Add reAuthenticate option to signInSilently to allow re-authentication to be requested * Updated Android lint settings. diff --git a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart index b45b09c2d7a7..04d60fbc7d21 100644 --- a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart +++ b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart @@ -314,11 +314,13 @@ class GoogleSignIn { /// successful sign in or `null` if there is no previously authenticated user. /// Use [signIn] method to trigger interactive sign in process. /// - /// Authentication process is triggered only if there is no currently signed in + /// Authentication is triggered if there is no currently signed in /// user (that is when `currentUser == null`), otherwise this method returns /// a Future which resolves to the same user instance. /// - /// Re-authentication can be triggered only after [signOut] or [disconnect]. + /// Re-authentication can be triggered after [signOut] or [disconnect]. It can + /// also be triggered by setting [reAuthenticate] to `true` if a new ID token + /// is required. /// /// When [suppressErrors] is set to `false` and an error occurred during sign in /// returned Future completes with [PlatformException] whose `code` can be @@ -327,10 +329,11 @@ class GoogleSignIn { /// (when an unknown error occurred). Future signInSilently({ bool suppressErrors = true, + bool reAuthenticate = false, }) async { try { return await _addMethodCall(GoogleSignInPlatform.instance.signInSilently, - canSkipCall: true); + canSkipCall: !reAuthenticate); } catch (_) { if (suppressErrors) { return null; diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index 7e3f221716a8..79009373c5d1 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.0.7 +version: 5.1.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart index f642bcd2eaf8..444edc4336ce 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart @@ -281,6 +281,22 @@ void main() { throwsA(isInstanceOf())); }); + test('signInSilently allows re-authentication to be requested', () async { + await googleSignIn.signInSilently(); + expect(googleSignIn.currentUser, isNotNull); + + await googleSignIn.signInSilently(reAuthenticate: true); + + expect( + log, + [ + _isSignInMethodCall(), + isMethodCall('signInSilently', arguments: null), + isMethodCall('signInSilently', arguments: null), + ], + ); + }); + test('can sign in after init failed before', () async { int initCount = 0; channel.setMockMethodCallHandler((MethodCall methodCall) { From 597771f5c6d4621d5346c86466b44c847aa529b7 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Fri, 3 Sep 2021 12:51:04 -0700 Subject: [PATCH 255/364] [camera_web] Make plugin publishable for the first time. (#4304) --- packages/camera/camera_web/CHANGELOG.md | 9 +--- packages/camera/camera_web/README.md | 55 +++++++++++++++++-------- packages/camera/camera_web/pubspec.yaml | 8 +--- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index a481554b540c..b116636c2808 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,8 +1,3 @@ -## 0.1.0+1 +## 0.2.0 -* Add `implements` to pubspec. - -## 0.1.0 - -* Initial release - * Added CameraOptions used to constrain the camera audio and video. +* Initial release, adapted from the Flutter [I/O Photobooth](https://photobooth.flutter.dev/) project. diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md index 8c216b3f4e0e..c6e1e0f13cab 100644 --- a/packages/camera/camera_web/README.md +++ b/packages/camera/camera_web/README.md @@ -6,7 +6,10 @@ The web implementation of [`camera`][camera]. ## Usage -This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), which means you can simply use `camera` normally. This package will be automatically included in your app when you do. +### Depend on the package + +This package is not an [endorsed implementation](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin) +of the google_maps_flutter plugin yet, so you'll need to [add it explicitly](https://pub.dev/packages/camera_web/install). ## Example @@ -16,41 +19,59 @@ Find the example in the [`camera` package](https://pub.dev/packages/camera#examp ### Camera devices -The camera devices are accessed with [Stream Web API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Streams_API) with the following [browser support](https://caniuse.com/stream): +The camera devices are accessed with [Stream Web API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Streams_API) +with the following [browser support](https://caniuse.com/stream): ![Data on support for the Stream feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/stream.png) -Accessing camera devices requires a [secure browsing context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). This means that you might need to serve your web application over HTTPS. For insecure contexts `CameraPlatform.availableCameras` might throw a `CameraException` with the `permissionDenied` error code. +Accessing camera devices requires a [secure browsing context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). +Broadly speaking, this means that you need to serve your web application over HTTPS +(or `localhost` for local development). For insecure contexts +`CameraPlatform.availableCameras` might throw a `CameraException` with the +`permissionDenied` error code. ### Device orientation -The device orientation implementation is backed by [`Screen Orientation Web API`](https://www.w3.org/TR/screen-orientation/) with the following [browser support](https://caniuse.com/screen-orientation): +The device orientation implementation is backed by [`Screen Orientation Web API`](https://www.w3.org/TR/screen-orientation/) +with the following [browser support](https://caniuse.com/screen-orientation): ![Data on support for the Screen Orientation feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/screen-orientation.png) For the browsers that do not support the device orientation: + - `CameraPlatform.onDeviceOrientationChanged` returns an empty stream. -- `CameraPlatform.lockCaptureOrientation` and `CameraPlatform.unlockCaptureOrientation` throw a `PlatformException` with the `orientationNotSupported` error code. +- `CameraPlatform.lockCaptureOrientation` and `CameraPlatform.unlockCaptureOrientation` +throw a `PlatformException` with the `orientationNotSupported` error code. ### Flash mode and zoom level -The flash mode and zoom level implementation is backed by [Image Capture Web API](https://w3c.github.io/mediacapture-image/) with the following [browser support](https://caniuse.com/mdn-api_imagecapture) (as of 12 August 2021): +The flash mode and zoom level implementation is backed by [Image Capture Web API](https://w3c.github.io/mediacapture-image/) +with the following [browser support](https://caniuse.com/mdn-api_imagecapture): -![Data on support for the Image Capture feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/static/v1/mdn-api__ImageCapture-1628778966589.png) +![Data on support for the Image Capture feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/static/v1/mdn-api__ImageCapture-1628778966589.png) For the browsers that do not support the flash mode: -- `CameraPlatform.setFlashMode` throws a `PlatformException` with the `torchModeNotSupported` error code. + +- `CameraPlatform.setFlashMode` throws a `PlatformException` with the +`torchModeNotSupported` error code. For the browsers that do not support the zoom level: -- `CameraPlatform.getMaxZoomLevel`, `CameraPlatform.getMinZoomLevel` and `CameraPlatform.setZoomLevel` throw a `PlatformException` with the `zoomLevelNotSupported` error code. + +- `CameraPlatform.getMaxZoomLevel`, `CameraPlatform.getMinZoomLevel` and +`CameraPlatform.setZoomLevel` throw a `PlatformException` with the +`zoomLevelNotSupported` error code. ### Taking a picture -The image capturing implementation is backed by [`URL.createObjectUrl` Web API](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL) with the following [browser support](https://caniuse.com/bloburls): +The image capturing implementation is backed by [`URL.createObjectUrl` Web API](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL) +with the following [browser support](https://caniuse.com/bloburls): ![Data on support for the Blob URLs feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/bloburls.png) -The web platform does not support `dart:io`. Attempts to display a captured image using `Image.file` will throw an error. The capture image contains a network-accessible URL pointing to a location within the browser and should be displayed using `Image.network` or `Image.memory` after loading the image bytes to memory. +The web platform does not support `dart:io`. Attempts to display a captured image +using `Image.file` will throw an error. The capture image contains a network-accessible +URL pointing to a location within the browser (blob) and can be displayed using +`Image.network` or `Image.memory` after loading the image bytes to memory. See the example below: @@ -65,13 +86,13 @@ if (kIsWeb) { ## Missing implementation The web implementation of [`camera`][camera] is missing the following features: -- Video recording + +- Video recording ([in progress](https://github.com/flutter/plugins/pull/4210)) - Exposure mode, point and offset - Focus mode and point -- Camera closing events -- Camera sensor orientation -- Camera image format group -- Camera image streaming +- Sensor orientation +- Image format group +- Streaming of frames -[camera]: https://pub.dev/packages/camera \ No newline at end of file +[camera]: https://pub.dev/packages/camera diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index 70194d9037d4..fdfe3e38bb98 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,12 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.1.0+1 - -# This plugin is under development and will be published -# when the first working web camera implementation is added. -# TODO(bselwe): Remove when camera_web should be published. -publish_to: none +version: 0.2.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -27,6 +22,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter + stream_transform: ^2.0.0 dev_dependencies: flutter_test: From 189a45eeb89465366051ea54d2fc14d70681be10 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Fri, 3 Sep 2021 15:51:04 -0700 Subject: [PATCH 256/364] build-examples .pluginToolsConfig.yaml support (#4305) --- script/tool/CHANGELOG.md | 4 + .../tool/lib/src/build_examples_command.dart | 74 ++++++++++++++++++- script/tool/pubspec.yaml | 2 +- .../test/build_examples_command_test.dart | 48 +++++++++++- 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 098e57a8c62d..aa73c65f3e80 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.7.1 + +- Add support for `.pluginToolsConfig.yaml` in the `build-examples` command. + ## 0.7.0 - `native-test` now supports `--linux` for unit tests. diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart index 56c2f5c7dc87..82ed074c462a 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:platform/platform.dart'; +import 'package:yaml/yaml.dart'; import 'common/core.dart'; import 'common/package_looping_command.dart'; @@ -16,7 +17,19 @@ import 'common/repository_package.dart'; /// Key for APK. const String _platformFlagApk = 'apk'; +const String _pluginToolsConfigFileName = '.pluginToolsConfig.yaml'; +const String _pluginToolsConfigBuildFlagsKey = 'buildFlags'; +const String _pluginToolsConfigGlobalKey = 'global'; + +const String _pluginToolsConfigExample = ''' +$_pluginToolsConfigBuildFlagsKey: + $_pluginToolsConfigGlobalKey: + - "--no-tree-shake-icons" + - "--dart-define=buildmode=testing" +'''; + const int _exitNoPlatformFlags = 3; +const int _exitInvalidPluginToolsConfig = 4; // Flutter build types. These are the values passed to `flutter build `. const String _flutterBuildTypeAndroid = 'apk'; @@ -99,7 +112,13 @@ class BuildExamplesCommand extends PackageLoopingCommand { @override final String description = 'Builds all example apps (IPA for iOS and APK for Android).\n\n' - 'This command requires "flutter" to be in your path.'; + 'This command requires "flutter" to be in your path.\n\n' + 'A $_pluginToolsConfigFileName file can be placed in an example app ' + 'directory to specify additional build arguments. It should be a YAML ' + 'file with a top-level map containing a single key ' + '"$_pluginToolsConfigBuildFlagsKey" containing a map containing a ' + 'single key "$_pluginToolsConfigGlobalKey" containing a list of build ' + 'arguments.'; @override Future initializeRun() async { @@ -202,6 +221,58 @@ class BuildExamplesCommand extends PackageLoopingCommand { : PackageResult.fail(errors); } + Iterable _readExtraBuildFlagsConfiguration( + Directory directory) sync* { + final File pluginToolsConfig = + directory.childFile(_pluginToolsConfigFileName); + if (pluginToolsConfig.existsSync()) { + final Object? configuration = + loadYaml(pluginToolsConfig.readAsStringSync()); + if (configuration is! YamlMap) { + printError('The $_pluginToolsConfigFileName file must be a YAML map.'); + printError( + 'Currently, the key "$_pluginToolsConfigBuildFlagsKey" is the only one that has an effect.'); + printError( + 'It must itself be a map. Currently, in that map only the key "$_pluginToolsConfigGlobalKey"'); + printError( + 'has any effect; it must contain a list of arguments to pass to the'); + printError('flutter tool.'); + printError(_pluginToolsConfigExample); + throw ToolExit(_exitInvalidPluginToolsConfig); + } + if (configuration.containsKey(_pluginToolsConfigBuildFlagsKey)) { + final Object? buildFlagsConfiguration = + configuration[_pluginToolsConfigBuildFlagsKey]; + if (buildFlagsConfiguration is! YamlMap) { + printError( + 'The $_pluginToolsConfigFileName file\'s "$_pluginToolsConfigBuildFlagsKey" key must be a map.'); + printError( + 'Currently, in that map only the key "$_pluginToolsConfigGlobalKey" has any effect; it must '); + printError( + 'contain a list of arguments to pass to the flutter tool.'); + printError(_pluginToolsConfigExample); + throw ToolExit(_exitInvalidPluginToolsConfig); + } + if (buildFlagsConfiguration.containsKey(_pluginToolsConfigGlobalKey)) { + final Object? globalBuildFlagsConfiguration = + buildFlagsConfiguration[_pluginToolsConfigGlobalKey]; + if (globalBuildFlagsConfiguration is! YamlList) { + printError( + 'The $_pluginToolsConfigFileName file\'s "$_pluginToolsConfigBuildFlagsKey" key must be a map'); + printError('whose "$_pluginToolsConfigGlobalKey" key is a list.'); + printError( + 'That list must contain a list of arguments to pass to the flutter tool.'); + printError( + 'For example, the $_pluginToolsConfigFileName file could look like:'); + printError(_pluginToolsConfigExample); + throw ToolExit(_exitInvalidPluginToolsConfig); + } + yield* globalBuildFlagsConfiguration.cast(); + } + } + } + } + Future _buildExample( RepositoryPackage example, String flutterBuildType, { @@ -231,6 +302,7 @@ class BuildExamplesCommand extends PackageLoopingCommand { 'build', flutterBuildType, ...extraBuildFlags, + ..._readExtraBuildFlagsConfiguration(example.directory), if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 2569e0ede870..689618f06123 100644 --- a/script/tool/pubspec.yaml +++ b/script/tool/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_plugin_tools description: Productivity utils for flutter/plugins and flutter/packages repository: https://github.com/flutter/plugins/tree/master/script/tool -version: 0.7.0 +version: 0.7.1 dependencies: args: ^2.1.0 diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index d9cbad246d28..c3b0cb9d5cd1 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -379,7 +379,6 @@ void main() { ]), ); - print(processRunner.recordedCalls); // Output should be empty since running build-examples --macos with no macos // implementation is a no-op. expect(processRunner.recordedCalls, orderedEquals([])); @@ -407,7 +406,6 @@ void main() { ]), ); - print(processRunner.recordedCalls); expect( processRunner.recordedCalls, containsAll([ @@ -436,7 +434,6 @@ void main() { contains('Creating temporary winuwp folder'), ); - print(processRunner.recordedCalls); expect( processRunner.recordedCalls, orderedEquals([ @@ -679,5 +676,50 @@ void main() { ])); }); }); + + test('The .pluginToolsConfig.yaml file', () async { + mockPlatform.isLinux = true; + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + final File pluginExampleConfigFile = + pluginExampleDirectory.childFile('.pluginToolsConfig.yaml'); + pluginExampleConfigFile + .writeAsStringSync('buildFlags:\n global:\n - "test argument"'); + + final List output = [ + ...await runCapturingPrint( + runner, ['build-examples', '--linux']), + ...await runCapturingPrint( + runner, ['build-examples', '--macos']), + ]; + + expect( + output, + containsAllInOrder([ + '\nBUILDING plugin/example for Linux', + '\nBUILDING plugin/example for macOS', + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + getFlutterCommand(mockPlatform), + const ['build', 'linux', 'test argument'], + pluginExampleDirectory.path), + ProcessCall( + getFlutterCommand(mockPlatform), + const ['build', 'macos', 'test argument'], + pluginExampleDirectory.path), + ])); + }); }); } From 17c41495b97514fb599233582efb3d2ab4a0eeef Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 3 Sep 2021 18:56:06 -0400 Subject: [PATCH 257/364] Remove gradle.properties from plugins (#4309) --- packages/android_alarm_manager/android/gradle.properties | 1 - packages/android_intent/android/gradle.properties | 1 - packages/battery/battery/android/gradle.properties | 1 - packages/camera/camera/android/gradle.properties | 1 - packages/connectivity/connectivity/android/gradle.properties | 1 - packages/device_info/device_info/android/gradle.properties | 1 - packages/espresso/android/gradle.properties | 4 ---- .../android/gradle.properties | 4 ---- .../google_maps_flutter/android/gradle.properties | 4 ---- .../google_sign_in/google_sign_in/android/gradle.properties | 4 ---- packages/image_picker/image_picker/android/gradle.properties | 1 - .../in_app_purchase_android/android/gradle.properties | 1 - packages/local_auth/android/gradle.properties | 1 - packages/package_info/android/gradle.properties | 1 - .../path_provider/path_provider/android/gradle.properties | 1 - .../quick_actions/quick_actions/android/gradle.properties | 1 - packages/sensors/android/gradle.properties | 1 - packages/share/android/gradle.properties | 1 - .../shared_preferences/android/gradle.properties | 1 - packages/url_launcher/url_launcher/android/gradle.properties | 1 - packages/video_player/video_player/android/gradle.properties | 1 - .../wifi_info_flutter/android/gradle.properties | 3 --- 22 files changed, 36 deletions(-) delete mode 100644 packages/android_alarm_manager/android/gradle.properties delete mode 100644 packages/android_intent/android/gradle.properties delete mode 100644 packages/battery/battery/android/gradle.properties delete mode 100644 packages/camera/camera/android/gradle.properties delete mode 100644 packages/connectivity/connectivity/android/gradle.properties delete mode 100644 packages/device_info/device_info/android/gradle.properties delete mode 100644 packages/espresso/android/gradle.properties delete mode 100644 packages/flutter_plugin_android_lifecycle/android/gradle.properties delete mode 100644 packages/google_maps_flutter/google_maps_flutter/android/gradle.properties delete mode 100644 packages/google_sign_in/google_sign_in/android/gradle.properties delete mode 100755 packages/image_picker/image_picker/android/gradle.properties delete mode 100644 packages/in_app_purchase/in_app_purchase_android/android/gradle.properties delete mode 100644 packages/local_auth/android/gradle.properties delete mode 100644 packages/package_info/android/gradle.properties delete mode 100644 packages/path_provider/path_provider/android/gradle.properties delete mode 100644 packages/quick_actions/quick_actions/android/gradle.properties delete mode 100644 packages/sensors/android/gradle.properties delete mode 100644 packages/share/android/gradle.properties delete mode 100644 packages/shared_preferences/shared_preferences/android/gradle.properties delete mode 100644 packages/url_launcher/url_launcher/android/gradle.properties delete mode 100644 packages/video_player/video_player/android/gradle.properties delete mode 100644 packages/wifi_info_flutter/wifi_info_flutter/android/gradle.properties diff --git a/packages/android_alarm_manager/android/gradle.properties b/packages/android_alarm_manager/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/android_alarm_manager/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/android_intent/android/gradle.properties b/packages/android_intent/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/android_intent/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/battery/battery/android/gradle.properties b/packages/battery/battery/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/battery/battery/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/camera/camera/android/gradle.properties b/packages/camera/camera/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/camera/camera/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/connectivity/connectivity/android/gradle.properties b/packages/connectivity/connectivity/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/connectivity/connectivity/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/device_info/device_info/android/gradle.properties b/packages/device_info/device_info/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/device_info/device_info/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/espresso/android/gradle.properties b/packages/espresso/android/gradle.properties deleted file mode 100644 index 38c8d4544ff1..000000000000 --- a/packages/espresso/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true -android.useAndroidX=true -android.enableJetifier=true diff --git a/packages/flutter_plugin_android_lifecycle/android/gradle.properties b/packages/flutter_plugin_android_lifecycle/android/gradle.properties deleted file mode 100644 index 38c8d4544ff1..000000000000 --- a/packages/flutter_plugin_android_lifecycle/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true -android.useAndroidX=true -android.enableJetifier=true diff --git a/packages/google_maps_flutter/google_maps_flutter/android/gradle.properties b/packages/google_maps_flutter/google_maps_flutter/android/gradle.properties deleted file mode 100644 index 38c8d4544ff1..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true -android.useAndroidX=true -android.enableJetifier=true diff --git a/packages/google_sign_in/google_sign_in/android/gradle.properties b/packages/google_sign_in/google_sign_in/android/gradle.properties deleted file mode 100644 index 38c8d4544ff1..000000000000 --- a/packages/google_sign_in/google_sign_in/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true -android.useAndroidX=true -android.enableJetifier=true diff --git a/packages/image_picker/image_picker/android/gradle.properties b/packages/image_picker/image_picker/android/gradle.properties deleted file mode 100755 index 8bd86f680510..000000000000 --- a/packages/image_picker/image_picker/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties b/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/local_auth/android/gradle.properties b/packages/local_auth/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/local_auth/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/package_info/android/gradle.properties b/packages/package_info/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/package_info/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/path_provider/path_provider/android/gradle.properties b/packages/path_provider/path_provider/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/path_provider/path_provider/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/quick_actions/quick_actions/android/gradle.properties b/packages/quick_actions/quick_actions/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/quick_actions/quick_actions/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/sensors/android/gradle.properties b/packages/sensors/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/sensors/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/share/android/gradle.properties b/packages/share/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/share/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/shared_preferences/shared_preferences/android/gradle.properties b/packages/shared_preferences/shared_preferences/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/shared_preferences/shared_preferences/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/url_launcher/url_launcher/android/gradle.properties b/packages/url_launcher/url_launcher/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/url_launcher/url_launcher/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/video_player/video_player/android/gradle.properties b/packages/video_player/video_player/android/gradle.properties deleted file mode 100644 index 8bd86f680510..000000000000 --- a/packages/video_player/video_player/android/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M diff --git a/packages/wifi_info_flutter/wifi_info_flutter/android/gradle.properties b/packages/wifi_info_flutter/wifi_info_flutter/android/gradle.properties deleted file mode 100644 index 94adc3a3f97a..000000000000 --- a/packages/wifi_info_flutter/wifi_info_flutter/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.useAndroidX=true -android.enableJetifier=true From 300100b34397f4023f5e72faa60b863dfe4a3d08 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 3 Sep 2021 19:01:04 -0400 Subject: [PATCH 258/364] [flutter_plugin_tools] Adjust diff logging (#4312) --- .../lib/src/common/git_version_finder.dart | 21 +++++++++++-------- .../tool/lib/src/common/plugin_command.dart | 3 +++ .../tool/lib/src/publish_plugin_command.dart | 3 +++ .../tool/test/common/plugin_command_test.dart | 9 +++++++- .../test/publish_plugin_command_test.dart | 2 ++ 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/script/tool/lib/src/common/git_version_finder.dart b/script/tool/lib/src/common/git_version_finder.dart index 2c9519e7a856..a0a7a32b5e0f 100644 --- a/script/tool/lib/src/common/git_version_finder.dart +++ b/script/tool/lib/src/common/git_version_finder.dart @@ -11,7 +11,7 @@ import 'package:yaml/yaml.dart'; /// Finding diffs based on `baseGitDir` and `baseSha`. class GitVersionFinder { /// Constructor - GitVersionFinder(this.baseGitDir, this.baseSha); + GitVersionFinder(this.baseGitDir, String? baseSha) : _baseSha = baseSha; /// The top level directory of the git repo. /// @@ -19,7 +19,7 @@ class GitVersionFinder { final GitDir baseGitDir; /// The base sha used to get diff. - final String? baseSha; + String? _baseSha; static bool _isPubspec(String file) { return file.trim().endsWith('pubspec.yaml'); @@ -32,10 +32,9 @@ class GitVersionFinder { /// Get a list of all the changed files. Future> getChangedFiles() async { - final String baseSha = await _getBaseSha(); + final String baseSha = await getBaseSha(); final io.ProcessResult changedFilesCommand = await baseGitDir .runCommand(['diff', '--name-only', baseSha, 'HEAD']); - print('Determine diff with base sha: $baseSha'); final String changedFilesStdout = changedFilesCommand.stdout.toString(); if (changedFilesStdout.isEmpty) { return []; @@ -49,7 +48,7 @@ class GitVersionFinder { /// at the revision of `gitRef` (defaulting to the base if not provided). Future getPackageVersion(String pubspecPath, {String? gitRef}) async { - final String ref = gitRef ?? (await _getBaseSha()); + final String ref = gitRef ?? (await getBaseSha()); io.ProcessResult gitShow; try { @@ -63,9 +62,11 @@ class GitVersionFinder { return versionString == null ? null : Version.parse(versionString); } - Future _getBaseSha() async { - if (baseSha != null && baseSha!.isNotEmpty) { - return baseSha!; + /// Returns the base used to diff against. + Future getBaseSha() async { + String? baseSha = _baseSha; + if (baseSha != null && baseSha.isNotEmpty) { + return baseSha; } io.ProcessResult baseShaFromMergeBase = await baseGitDir.runCommand( @@ -76,6 +77,8 @@ class GitVersionFinder { baseShaFromMergeBase = await baseGitDir .runCommand(['merge-base', 'FETCH_HEAD', 'HEAD']); } - return (baseShaFromMergeBase.stdout as String).trim(); + baseSha = (baseShaFromMergeBase.stdout as String).trim(); + _baseSha = baseSha; + return baseSha; } } diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart index 514a90b85cc7..5d5cbd9abf6c 100644 --- a/script/tool/lib/src/common/plugin_command.dart +++ b/script/tool/lib/src/common/plugin_command.dart @@ -314,6 +314,9 @@ abstract class PluginCommand extends Command { if (runOnChangedPackages) { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final String baseSha = await gitVersionFinder.getBaseSha(); + print( + 'Running for all packages that have changed relative to "$baseSha"\n'); final List changedFiles = await gitVersionFinder.getChangedFiles(); if (!_changesRequireFullTest(changedFiles)) { diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 6da51706ef1e..769b9e8c8f00 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -159,6 +159,9 @@ class PublishPluginCommand extends PackageLoopingCommand { Stream getPackagesToProcess() async* { if (getBoolArg(_allChangedFlag)) { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final String baseSha = await gitVersionFinder.getBaseSha(); + print( + 'Publishing all packages that have changed relative to "$baseSha"\n'); final List changedPubspecs = await gitVersionFinder.getChangedPubSpecs(); diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index 3ef0d3b3c005..13724e26e5f8 100644 --- a/script/tool/test/common/plugin_command_test.dart +++ b/script/tool/test/common/plugin_command_test.dart @@ -398,12 +398,19 @@ packages/plugin1/CHANGELOG ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir); createFakePlugin('plugin2', packagesDir); - await runCapturingPrint(runner, [ + final List output = await runCapturingPrint(runner, [ 'sample', '--base-sha=master', '--run-on-changed-packages' ]); + expect( + output, + containsAllInOrder([ + contains( + 'Running for all packages that have changed relative to "master"'), + ])); + expect(command.plugins, unorderedEquals([plugin1.path])); }); diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart index 2ea4fc753460..14e99a10f365 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -466,6 +466,8 @@ void main() { expect( output, containsAllInOrder([ + contains( + 'Publishing all packages that have changed relative to "HEAD~"'), contains('Running `pub publish ` in ${pluginDir1.path}...'), contains('Running `pub publish ` in ${pluginDir2.path}...'), contains('plugin1 - \x1B[32mpublished\x1B[0m'), From 998f51f8e8abc6a31366dd3338590c2e2ff99e06 Mon Sep 17 00:00:00 2001 From: Christopher Boyd Date: Sun, 5 Sep 2021 00:26:04 -0400 Subject: [PATCH 259/364] [image_picker] add forceFullMetadata to interface (#4288) --- .../lib/image_picker_for_web.dart | 2 + .../CHANGELOG.md | 8 +++ .../method_channel_image_picker.dart | 6 ++ .../image_picker_platform.dart | 12 ++++ .../pubspec.yaml | 2 +- .../new_method_channel_image_picker_test.dart | 58 +++++++++++++------ 6 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart index b170ee3256ab..e1ade78d997d 100644 --- a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart +++ b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart @@ -53,6 +53,7 @@ class ImagePickerPlugin extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { String? capture = computeCaptureAttribute(source, preferredCameraDevice); @@ -115,6 +116,7 @@ class ImagePickerPlugin extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { String? capture = computeCaptureAttribute(source, preferredCameraDevice); diff --git a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md index 97480e044284..3c14e404e4d1 100644 --- a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md +++ b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.4.0 + +* Add `forceFullMetadata` option to `pickImage`. + * To keep this non-breaking `forceFullMetadata` defaults to `true`, so the plugin tries + to get the full image metadata which may require extra permission requests on certain platforms. + * If `forceFullMetadata` is set to `false`, the plugin fetches the image in a way that reduces + permission requests from the platform (e.g on iOS the plugin won’t ask for the `NSPhotoLibraryUsageDescription` permission). + ## 2.3.0 * Updated `LostDataResponse` to include a `files` property, in case more than one file was recovered. diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart index b02284e957fa..082b40357a1f 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart @@ -24,6 +24,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { String? path = await _getImagePath( @@ -31,6 +32,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: imageQuality, + forceFullMetadata: forceFullMetadata, preferredCameraDevice: preferredCameraDevice, ); return path != null ? PickedFile(path) : null; @@ -85,6 +87,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { if (imageQuality != null && (imageQuality < 0 || imageQuality > 100)) { @@ -107,6 +110,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { 'maxWidth': maxWidth, 'maxHeight': maxHeight, 'imageQuality': imageQuality, + 'forceFullMetadata': forceFullMetadata, 'cameraDevice': preferredCameraDevice.index }, ); @@ -183,6 +187,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { String? path = await _getImagePath( @@ -190,6 +195,7 @@ class MethodChannelImagePicker extends ImagePickerPlatform { maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: imageQuality, + forceFullMetadata: forceFullMetadata, preferredCameraDevice: preferredCameraDevice, ); return path != null ? XFile(path) : null; diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart index 5c1c8b698442..60fa784c22a3 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart @@ -58,6 +58,11 @@ abstract class ImagePickerPlatform extends PlatformInterface { /// image types such as JPEG. If compression is not supported for the image that is picked, /// a warning message will be logged. /// + /// `forceFullMetadata` defaults to `true`, so the plugin tries to get the full image metadata which may require + /// extra permission requests on certain platforms. + /// If `forceFullMetadata` is set to `false`, the plugin fetches the image in a way that reduces permission requests + /// from the platform (e.g. on iOS the plugin won’t ask for the `NSPhotoLibraryUsageDescription` permission). + /// /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. /// Defaults to [CameraDevice.rear]. Note that Android has no documented parameter for an intent to specify if @@ -73,6 +78,7 @@ abstract class ImagePickerPlatform extends PlatformInterface { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { throw UnimplementedError('pickImage() has not been implemented.'); @@ -164,6 +170,11 @@ abstract class ImagePickerPlatform extends PlatformInterface { /// image types such as JPEG. If compression is not supported for the image that is picked, /// a warning message will be logged. /// + /// `forceFullMetadata` defaults to `true`, so the plugin tries to get the full image metadata which may require + /// extra permission requests on certain platforms. + /// If `forceFullMetadata` is set to `false`, the plugin fetches the image in a way that reduces permission requests + /// from the platform (e.g. on iOS the plugin won’t ask for the `NSPhotoLibraryUsageDescription` permission). + /// /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. /// Defaults to [CameraDevice.rear]. Note that Android has no documented parameter for an intent to specify if @@ -179,6 +190,7 @@ abstract class ImagePickerPlatform extends PlatformInterface { double? maxWidth, double? maxHeight, int? imageQuality, + bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { throw UnimplementedError('getImage() has not been implemented.'); diff --git a/packages/image_picker/image_picker_platform_interface/pubspec.yaml b/packages/image_picker/image_picker_platform_interface/pubspec.yaml index 2168ff0f778a..53852de4f1d3 100644 --- a/packages/image_picker/image_picker_platform_interface/pubspec.yaml +++ b/packages/image_picker/image_picker_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.3.0 +version: 2.4.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart index 17caa8456621..a2d9568fc85d 100644 --- a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart +++ b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart @@ -40,14 +40,16 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 1, 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), ], ); @@ -93,49 +95,56 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': 70, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': 70, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': 70, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), ], ); @@ -196,6 +205,7 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 0, + 'forceFullMetadata': true, }), ], ); @@ -215,6 +225,7 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 1, + 'forceFullMetadata': true, }), ], ); @@ -509,14 +520,16 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 1, 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), ], ); @@ -562,49 +575,56 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': null, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': 70, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': 70, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': 70, - 'cameraDevice': 0 + 'cameraDevice': 0, + 'forceFullMetadata': true, }), ], ); @@ -664,6 +684,7 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 0, + 'forceFullMetadata': true, }), ], ); @@ -683,6 +704,7 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 1, + 'forceFullMetadata': true, }), ], ); From 3a38cd09a736ebbd8ba6b29a1ceeb5dffd628fbd Mon Sep 17 00:00:00 2001 From: Zachary Anderson Date: Sun, 5 Sep 2021 17:24:24 -0700 Subject: [PATCH 260/364] Revert "[image_picker] add forceFullMetadata to interface" (#4314) Reverts #4288 which published a breaking change as if it were a non-breaking change. The discussion in that PR prior to it landing was incorrect, because adding a new parameter with a default value is non-breaking *only for clients*. It is breaking for subclasses that override it, and the purpose of the platform interface is for implementations to subclass it and override everything. --- .../lib/image_picker_for_web.dart | 2 - .../CHANGELOG.md | 5 ++ .../method_channel_image_picker.dart | 6 -- .../image_picker_platform.dart | 12 ---- .../pubspec.yaml | 2 +- .../new_method_channel_image_picker_test.dart | 58 ++++++------------- 6 files changed, 24 insertions(+), 61 deletions(-) diff --git a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart index e1ade78d997d..b170ee3256ab 100644 --- a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart +++ b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart @@ -53,7 +53,6 @@ class ImagePickerPlugin extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { String? capture = computeCaptureAttribute(source, preferredCameraDevice); @@ -116,7 +115,6 @@ class ImagePickerPlugin extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { String? capture = computeCaptureAttribute(source, preferredCameraDevice); diff --git a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md index 3c14e404e4d1..d637ac1a277e 100644 --- a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md +++ b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.4.1 + +* Reverts the changes from 2.4.0, which was a breaking change that + was incorrectly marked as a non-breaking change. + ## 2.4.0 * Add `forceFullMetadata` option to `pickImage`. diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart index 082b40357a1f..b02284e957fa 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart @@ -24,7 +24,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { String? path = await _getImagePath( @@ -32,7 +31,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: imageQuality, - forceFullMetadata: forceFullMetadata, preferredCameraDevice: preferredCameraDevice, ); return path != null ? PickedFile(path) : null; @@ -87,7 +85,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { if (imageQuality != null && (imageQuality < 0 || imageQuality > 100)) { @@ -110,7 +107,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { 'maxWidth': maxWidth, 'maxHeight': maxHeight, 'imageQuality': imageQuality, - 'forceFullMetadata': forceFullMetadata, 'cameraDevice': preferredCameraDevice.index }, ); @@ -187,7 +183,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) async { String? path = await _getImagePath( @@ -195,7 +190,6 @@ class MethodChannelImagePicker extends ImagePickerPlatform { maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: imageQuality, - forceFullMetadata: forceFullMetadata, preferredCameraDevice: preferredCameraDevice, ); return path != null ? XFile(path) : null; diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart index 60fa784c22a3..5c1c8b698442 100644 --- a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart +++ b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart @@ -58,11 +58,6 @@ abstract class ImagePickerPlatform extends PlatformInterface { /// image types such as JPEG. If compression is not supported for the image that is picked, /// a warning message will be logged. /// - /// `forceFullMetadata` defaults to `true`, so the plugin tries to get the full image metadata which may require - /// extra permission requests on certain platforms. - /// If `forceFullMetadata` is set to `false`, the plugin fetches the image in a way that reduces permission requests - /// from the platform (e.g. on iOS the plugin won’t ask for the `NSPhotoLibraryUsageDescription` permission). - /// /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. /// Defaults to [CameraDevice.rear]. Note that Android has no documented parameter for an intent to specify if @@ -78,7 +73,6 @@ abstract class ImagePickerPlatform extends PlatformInterface { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { throw UnimplementedError('pickImage() has not been implemented.'); @@ -170,11 +164,6 @@ abstract class ImagePickerPlatform extends PlatformInterface { /// image types such as JPEG. If compression is not supported for the image that is picked, /// a warning message will be logged. /// - /// `forceFullMetadata` defaults to `true`, so the plugin tries to get the full image metadata which may require - /// extra permission requests on certain platforms. - /// If `forceFullMetadata` is set to `false`, the plugin fetches the image in a way that reduces permission requests - /// from the platform (e.g. on iOS the plugin won’t ask for the `NSPhotoLibraryUsageDescription` permission). - /// /// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera]. /// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device. /// Defaults to [CameraDevice.rear]. Note that Android has no documented parameter for an intent to specify if @@ -190,7 +179,6 @@ abstract class ImagePickerPlatform extends PlatformInterface { double? maxWidth, double? maxHeight, int? imageQuality, - bool forceFullMetadata = true, CameraDevice preferredCameraDevice = CameraDevice.rear, }) { throw UnimplementedError('getImage() has not been implemented.'); diff --git a/packages/image_picker/image_picker_platform_interface/pubspec.yaml b/packages/image_picker/image_picker_platform_interface/pubspec.yaml index 53852de4f1d3..e41137fcb06b 100644 --- a/packages/image_picker/image_picker_platform_interface/pubspec.yaml +++ b/packages/image_picker/image_picker_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.4.0 +version: 2.4.1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart index a2d9568fc85d..17caa8456621 100644 --- a/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart +++ b/packages/image_picker/image_picker_platform_interface/test/new_method_channel_image_picker_test.dart @@ -40,16 +40,14 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 1, 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), ], ); @@ -95,56 +93,49 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': 70, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': 70, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': 70, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), ], ); @@ -205,7 +196,6 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 0, - 'forceFullMetadata': true, }), ], ); @@ -225,7 +215,6 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 1, - 'forceFullMetadata': true, }), ], ); @@ -520,16 +509,14 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 1, 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), ], ); @@ -575,56 +562,49 @@ void main() { 'maxWidth': null, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': null, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': null, 'imageQuality': 70, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': null, 'maxHeight': 10.0, 'imageQuality': 70, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), isMethodCall('pickImage', arguments: { 'source': 0, 'maxWidth': 10.0, 'maxHeight': 20.0, 'imageQuality': 70, - 'cameraDevice': 0, - 'forceFullMetadata': true, + 'cameraDevice': 0 }), ], ); @@ -684,7 +664,6 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 0, - 'forceFullMetadata': true, }), ], ); @@ -704,7 +683,6 @@ void main() { 'maxHeight': null, 'imageQuality': null, 'cameraDevice': 1, - 'forceFullMetadata': true, }), ], ); From ae92e6246a27212db04d29c9ba4a4d85cdaa810e Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 7 Sep 2021 14:52:02 -0400 Subject: [PATCH 261/364] Allow neutral conclusion in publishing check (#4321) It's possible for jobs to conclude in a "neutral" state; see https://github.com/flutter/plugins/runs/3534917860 for instance. Currently this causes "release" to be red when it shouldn't be. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f1a4a360949..2393eadab4c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: running-workflow-name: 'release' repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 180 # seconds - allowed-conclusions: success + allowed-conclusions: success,neutral # verbose:true will produce too many logs that hang github actions web UI. verbose: false From 98481816b1a13af09528d9353c80de4f0412cca5 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 8 Sep 2021 10:05:17 -0400 Subject: [PATCH 262/364] Bring up new Windows test targets (#4311) Uses the new generic plugins recipe to add a number of new Windows tests that have recently gained tool support, but not yet added to the LUCI builds: - New targets: - The "build all plugins" test (master and stable) - UWP build tests (master only, since UWP is not on stable yet) - Tool tests (master only with existing tool tests on Linux) - Replacement versions of the existing builders but with names that match the Cirrus naming to make the parallels in testing easier to understand in the GitHub UI and configs. - Modification of existing targets: - Adds Windows native unit tests to the existing script. --- .ci.yaml | 84 +++++++++++++++++++ .ci/scripts/build_all_plugins.sh | 8 ++ .ci/scripts/build_examples_uwp.sh | 7 ++ .ci/scripts/create_all_plugins_app.sh | 7 ++ .ci/scripts/native_test_win32.sh | 7 ++ .ci/scripts/plugin_tools_tests.sh | 7 ++ .ci/targets/build_all_plugins.yaml | 7 ++ .ci/targets/plugin_tools_tests.yaml | 5 ++ .ci/targets/uwp_build_and_platform_tests.yaml | 5 ++ .../windows_build_and_platform_tests.yaml | 9 +- .cirrus.yml | 2 +- 11 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 .ci/scripts/build_all_plugins.sh create mode 100644 .ci/scripts/build_examples_uwp.sh create mode 100644 .ci/scripts/create_all_plugins_app.sh create mode 100644 .ci/scripts/native_test_win32.sh create mode 100644 .ci/scripts/plugin_tools_tests.sh create mode 100644 .ci/targets/build_all_plugins.yaml create mode 100644 .ci/targets/plugin_tools_tests.yaml create mode 100644 .ci/targets/uwp_build_and_platform_tests.yaml diff --git a/.ci.yaml b/.ci.yaml index ebedd203b3ca..a63f149403d9 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -35,6 +35,7 @@ platform_properties: os: Windows targets: + # TODO(stuartmorgan) Remove once the renamed version below has propagated. - name: Windows Plugins master channel recipe: plugins/plugins timeout: 30 @@ -47,6 +48,7 @@ targets: ] scheduler: luci + # TODO(stuartmorgan) Remove once the renamed version below has propagated. - name: Windows Plugins stable channel recipe: plugins/plugins timeout: 30 @@ -60,6 +62,88 @@ targets: ] scheduler: luci + - name: Windows win32_build+platform-tests master + recipe: plugins/plugins + bringup: true + timeout: 30 + properties: + # TODO(stuartmorgan): Uncomment when removing bringup. + #add_recipes_cq: "true" + target_file: windows_build_and_platform_tests.yaml + dependencies: > + [ + {"dependency": "vs_build"} + ] + scheduler: luci + + - name: Windows win32_build+platform-tests stable + recipe: plugins/plugins + bringup: true + timeout: 30 + properties: + # TODO(stuartmorgan): Uncomment when removing bringup. + #add_recipes_cq: "true" + target_file: uwp_build_and_platform_tests.yaml + channel: stable + dependencies: > + [ + {"dependency": "vs_build"} + ] + scheduler: luci + + - name: Windows build_all_plugins master + recipe: plugins/plugins + bringup: true + timeout: 30 + properties: + # TODO(stuartmorgan): Uncomment when removing bringup. + #add_recipes_cq: "true" + target_file: build_all_plugins.yaml + dependencies: > + [ + {"dependency": "vs_build"} + ] + scheduler: luci + + - name: Windows build_all_plugins stable + recipe: plugins/plugins + bringup: true + timeout: 30 + properties: + # TODO(stuartmorgan): Uncomment when removing bringup. + #add_recipes_cq: "true" + target_file: build_all_plugins.yaml + channel: stable + dependencies: > + [ + {"dependency": "vs_build"} + ] + scheduler: luci + + - name: Windows uwp-build+platform-tests master + recipe: plugins/plugins + bringup: true + timeout: 30 + properties: + # TODO(stuartmorgan): Uncomment when removing bringup. + #add_recipes_cq: "true" + target_file: uwp_build_and_platform_tests.yaml + dependencies: > + [ + {"dependency": "vs_build"} + ] + scheduler: luci + + - name: Windows plugin_tools_tests + recipe: plugins/plugins + bringup: true + timeout: 30 + properties: + # TODO(stuartmorgan): Uncomment when removing bringup. + #add_recipes_cq: "true" + target_file: plugin_tools_tests.yaml + scheduler: luci + - name: Linux ci_yaml plugins roller recipe: infra/ci_yaml timeout: 30 diff --git a/.ci/scripts/build_all_plugins.sh b/.ci/scripts/build_all_plugins.sh new file mode 100644 index 000000000000..008dea7c5e13 --- /dev/null +++ b/.ci/scripts/build_all_plugins.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright 2013 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. + +cd all_plugins +flutter build windows --debug +flutter build windows --release diff --git a/.ci/scripts/build_examples_uwp.sh b/.ci/scripts/build_examples_uwp.sh new file mode 100644 index 000000000000..639cb054e4b7 --- /dev/null +++ b/.ci/scripts/build_examples_uwp.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 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. + +dart ./script/tool/bin/flutter_plugin_tools.dart build-examples --winuwp \ + --packages-for-branch diff --git a/.ci/scripts/create_all_plugins_app.sh b/.ci/scripts/create_all_plugins_app.sh new file mode 100644 index 000000000000..196fef9b06c9 --- /dev/null +++ b/.ci/scripts/create_all_plugins_app.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 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. + +dart ./script/tool/bin/flutter_plugin_tools.dart all-plugins-app \ + --output-dir=. --exclude script/configs/exclude_all_plugins_app.yaml diff --git a/.ci/scripts/native_test_win32.sh b/.ci/scripts/native_test_win32.sh new file mode 100644 index 000000000000..938515784412 --- /dev/null +++ b/.ci/scripts/native_test_win32.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 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. + +dart ./script/tool/bin/flutter_plugin_tools.dart native-test --windows \ + --no-integration --packages-for-branch diff --git a/.ci/scripts/plugin_tools_tests.sh b/.ci/scripts/plugin_tools_tests.sh new file mode 100644 index 000000000000..96eec4349f08 --- /dev/null +++ b/.ci/scripts/plugin_tools_tests.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 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. + +cd script/tool +dart pub run test diff --git a/.ci/targets/build_all_plugins.yaml b/.ci/targets/build_all_plugins.yaml new file mode 100644 index 000000000000..b51a5b18dfd9 --- /dev/null +++ b/.ci/targets/build_all_plugins.yaml @@ -0,0 +1,7 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: create all_plugins app + script: .ci/scripts/create_all_plugins_app.sh + - name: build all_plugins + script: .ci/scripts/build_all_plugins.sh diff --git a/.ci/targets/plugin_tools_tests.yaml b/.ci/targets/plugin_tools_tests.yaml new file mode 100644 index 000000000000..265e74bdd06b --- /dev/null +++ b/.ci/targets/plugin_tools_tests.yaml @@ -0,0 +1,5 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: tool unit tests + script: .ci/scripts/plugin_tools_tests.sh diff --git a/.ci/targets/uwp_build_and_platform_tests.yaml b/.ci/targets/uwp_build_and_platform_tests.yaml new file mode 100644 index 000000000000..a7f070776ff1 --- /dev/null +++ b/.ci/targets/uwp_build_and_platform_tests.yaml @@ -0,0 +1,5 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: build examples (UWP) + script: .ci/scripts/build_examples_uwp.sh diff --git a/.ci/targets/windows_build_and_platform_tests.yaml b/.ci/targets/windows_build_and_platform_tests.yaml index cba120073310..cda3e57f75d2 100644 --- a/.ci/targets/windows_build_and_platform_tests.yaml +++ b/.ci/targets/windows_build_and_platform_tests.yaml @@ -1,8 +1,9 @@ - tasks: - - name: "prepare tool" + - name: prepare tool script: .ci/scripts/prepare_tool.sh - - name: "build examples" + - name: build examples (Win32) script: .ci/scripts/build_examples_win32.sh - - name: "drive examples" + - name: native unit tests (Win32) + script: .ci/scripts/native_test_win32.sh + - name: drive examples (Win32) script: .ci/scripts/drive_examples_win32.sh diff --git a/.cirrus.yml b/.cirrus.yml index 10d668d8d1d7..56f312dea929 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -66,7 +66,7 @@ task: - name: plugin_tools_tests script: - cd script/tool - - CIRRUS_BUILD_ID=null pub run test + - dart pub run test - name: publishable version_check_script: ./script/tool_runner.sh version-check publish_check_script: ./script/tool_runner.sh publish-check From 2e84965c3132a92b219f870ead2f5514931e24e2 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 8 Sep 2021 12:31:16 -0400 Subject: [PATCH 263/364] [ci] Fix and standardize target/task names (#4323) Fixes the target names in LUCI to not use '+', since it's not supported. To better align the names between the two different infrastructures, also updates the task naming in cirrus.yml to not use +. It also standardizes the naming form across both systems as: ` - ` where: - `` is always there on LUCI, where required, but only added where it's potentially ambiguous on Cirrus. - `' is omitted when the test is not target-platform specific. - `' uses underscores, which is consistent with flutter/flutter (and with Cirrus step naming). - `` is only explicitly set (to the channel) on LUCI; Cirrus automatically adds channel info there due to the way `matrix` works. --- .ci.yaml | 12 ++++++------ .cirrus.yml | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.ci.yaml b/.ci.yaml index a63f149403d9..cf0fdc807a01 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -62,7 +62,7 @@ targets: ] scheduler: luci - - name: Windows win32_build+platform-tests master + - name: Windows win32-platform_tests master recipe: plugins/plugins bringup: true timeout: 30 @@ -76,14 +76,14 @@ targets: ] scheduler: luci - - name: Windows win32_build+platform-tests stable + - name: Windows win32-platform_tests stable recipe: plugins/plugins bringup: true timeout: 30 properties: # TODO(stuartmorgan): Uncomment when removing bringup. #add_recipes_cq: "true" - target_file: uwp_build_and_platform_tests.yaml + target_file: windows_build_and_platform_tests.yaml channel: stable dependencies: > [ @@ -91,7 +91,7 @@ targets: ] scheduler: luci - - name: Windows build_all_plugins master + - name: Windows windows-build_all_plugins master recipe: plugins/plugins bringup: true timeout: 30 @@ -105,7 +105,7 @@ targets: ] scheduler: luci - - name: Windows build_all_plugins stable + - name: Windows windows-build_all_plugins stable recipe: plugins/plugins bringup: true timeout: 30 @@ -120,7 +120,7 @@ targets: ] scheduler: luci - - name: Windows uwp-build+platform-tests master + - name: Windows uwp-platform_tests master recipe: plugins/plugins bringup: true timeout: 30 diff --git a/.cirrus.yml b/.cirrus.yml index 56f312dea929..9ca3a875c01e 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -63,7 +63,7 @@ task: namespace: default matrix: ### Platform-agnostic tasks ### - - name: plugin_tools_tests + - name: Linux plugin_tools_tests script: - cd script/tool - dart pub run test @@ -74,7 +74,7 @@ task: format_script: ./script/tool_runner.sh format --fail-on-change pubspec_script: ./script/tool_runner.sh pubspec-check license_script: dart $PLUGIN_TOOL license-check - - name: test + - name: dart_unit_tests env: matrix: CHANNEL: "master" @@ -94,7 +94,7 @@ task: # See the comment in script/configs/custom_analysis.yaml for details. - ./script/tool_runner.sh analyze --custom-analysis=script/configs/custom_analysis.yaml ### Android tasks ### - - name: build_all_plugins_apk + - name: android-build_all_plugins env: BUILD_ALL_ARGS: "apk" matrix: @@ -102,7 +102,7 @@ task: CHANNEL: "stable" << : *BUILD_ALL_PLUGINS_APP_TEMPLATE ### Web tasks ### - - name: build_all_plugins_web + - name: web-build_all_plugins env: BUILD_ALL_ARGS: "web" matrix: @@ -110,7 +110,7 @@ task: CHANNEL: "stable" << : *BUILD_ALL_PLUGINS_APP_TEMPLATE ### Linux desktop tasks ### - - name: build_all_plugins_linux + - name: linux-build_all_plugins env: BUILD_ALL_ARGS: "linux" matrix: @@ -119,7 +119,7 @@ task: setup_script: - flutter config --enable-linux-desktop << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - - name: linux-build+platform-tests + - name: linux-platform_tests env: matrix: CHANNEL: "master" @@ -148,7 +148,7 @@ task: memory: 12G matrix: ### Android tasks ### - - name: android-build+platform-tests + - name: android-platform_tests env: matrix: PLUGIN_SHARDING: "--shardIndex 0 --shardCount 4" @@ -202,7 +202,7 @@ task: type: text/xml format: android-lint ### Web tasks ### - - name: web-build+platform-tests + - name: web-platform_tests env: matrix: CHANNEL: "master" @@ -224,18 +224,18 @@ task: << : *FLUTTER_UPGRADE_TEMPLATE matrix: ### iOS+macOS tasks *** - - name: lint_darwin_plugins + - name: darwin-lint_podspecs script: - ./script/tool_runner.sh podspecs ### iOS tasks ### - - name: build_all_plugins_ipa + - name: ios-build_all_plugins env: BUILD_ALL_ARGS: "ios --no-codesign" matrix: CHANNEL: "master" CHANNEL: "stable" << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - - name: ios-build+platform-tests + - name: ios-platform_tests env: PATH: $PATH:/usr/local/bin matrix: @@ -262,7 +262,7 @@ task: # So we run `drive-examples` after `native-test`; changing the order will result ci failure. - ./script/tool_runner.sh drive-examples --ios --exclude=script/configs/exclude_integration_ios.yaml ### macOS desktop tasks ### - - name: build_all_plugins_macos + - name: macos-build_all_plugins env: BUILD_ALL_ARGS: "macos" matrix: @@ -271,7 +271,7 @@ task: setup_script: - flutter config --enable-macos-desktop << : *BUILD_ALL_PLUGINS_APP_TEMPLATE - - name: macos-build+platform-tests + - name: macos-platform_tests env: matrix: CHANNEL: "master" From f988f6191a2ddb162c962ea5ac00cda4a00d9783 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Wed, 8 Sep 2021 14:47:04 -0700 Subject: [PATCH 264/364] [ci] Update lewagon/wait-on-check-action to latest version. (#4326) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2393eadab4c5..9fbe499f0963 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: # This workflow should be the last to run. So wait for all the other tests to succeed. - name: Wait on all tests - uses: lewagon/wait-on-check-action@a0f99ce1e713de216866868c3da4d4183a051cbe + uses: lewagon/wait-on-check-action@5e937358caba2c7876a2ee06e4a48d0664fe4967 with: ref: ${{ github.sha }} running-workflow-name: 'release' From f460b7967f4b1aa491c1be8cc9f4ad8c9744b067 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 9 Sep 2021 09:59:18 -0400 Subject: [PATCH 265/364] [ci] Enable the new Windows targets (#4325) Now that the builders have propagated, enable all the new tests and remove the obsolete versions. --- .ci.yaml | 51 +++---------------- .../test/drive_examples_command_test.dart | 4 +- 2 files changed, 9 insertions(+), 46 deletions(-) diff --git a/.ci.yaml b/.ci.yaml index cf0fdc807a01..00aecdca1122 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -35,40 +35,11 @@ platform_properties: os: Windows targets: - # TODO(stuartmorgan) Remove once the renamed version below has propagated. - - name: Windows Plugins master channel - recipe: plugins/plugins - timeout: 30 - properties: - add_recipes_cq: "true" - target_file: windows_build_and_platform_tests.yaml - dependencies: > - [ - {"dependency": "vs_build"} - ] - scheduler: luci - - # TODO(stuartmorgan) Remove once the renamed version below has propagated. - - name: Windows Plugins stable channel - recipe: plugins/plugins - timeout: 30 - properties: - add_recipes_cq: "true" - target_file: windows_build_and_platform_tests.yaml - channel: stable - dependencies: > - [ - {"dependency": "vs_build"} - ] - scheduler: luci - - name: Windows win32-platform_tests master recipe: plugins/plugins - bringup: true timeout: 30 properties: - # TODO(stuartmorgan): Uncomment when removing bringup. - #add_recipes_cq: "true" + add_recipes_cq: "true" target_file: windows_build_and_platform_tests.yaml dependencies: > [ @@ -78,11 +49,9 @@ targets: - name: Windows win32-platform_tests stable recipe: plugins/plugins - bringup: true timeout: 30 properties: - # TODO(stuartmorgan): Uncomment when removing bringup. - #add_recipes_cq: "true" + add_recipes_cq: "true" target_file: windows_build_and_platform_tests.yaml channel: stable dependencies: > @@ -93,11 +62,9 @@ targets: - name: Windows windows-build_all_plugins master recipe: plugins/plugins - bringup: true timeout: 30 properties: - # TODO(stuartmorgan): Uncomment when removing bringup. - #add_recipes_cq: "true" + add_recipes_cq: "true" target_file: build_all_plugins.yaml dependencies: > [ @@ -107,11 +74,9 @@ targets: - name: Windows windows-build_all_plugins stable recipe: plugins/plugins - bringup: true timeout: 30 properties: - # TODO(stuartmorgan): Uncomment when removing bringup. - #add_recipes_cq: "true" + add_recipes_cq: "true" target_file: build_all_plugins.yaml channel: stable dependencies: > @@ -122,11 +87,9 @@ targets: - name: Windows uwp-platform_tests master recipe: plugins/plugins - bringup: true timeout: 30 properties: - # TODO(stuartmorgan): Uncomment when removing bringup. - #add_recipes_cq: "true" + add_recipes_cq: "true" target_file: uwp_build_and_platform_tests.yaml dependencies: > [ @@ -136,11 +99,9 @@ targets: - name: Windows plugin_tools_tests recipe: plugins/plugins - bringup: true timeout: 30 properties: - # TODO(stuartmorgan): Uncomment when removing bringup. - #add_recipes_cq: "true" + add_recipes_cq: "true" target_file: plugin_tools_tests.yaml scheduler: luci diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index 85d2326d0689..a7a1652c2fc2 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.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:convert'; import 'dart:io' as io; import 'package:args/command_runner.dart'; @@ -60,7 +61,8 @@ void main() { final String output = '''${includeBanner ? updateBanner : ''}[${devices.join(',')}]'''; - final MockProcess mockDevicesProcess = MockProcess(stdout: output); + final MockProcess mockDevicesProcess = + MockProcess(stdout: output, stdoutEncoding: utf8); processRunner .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [mockDevicesProcess]; From 5266acb44c868b4df34493b7d863c0bfc9e2a798 Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Thu, 9 Sep 2021 09:37:06 -0700 Subject: [PATCH 266/364] Ignore unnecessary_import in legacy analysis options (#4129) --- analysis_options_legacy.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/analysis_options_legacy.yaml b/analysis_options_legacy.yaml index 2b62a6a9e2b9..793640e22d27 100644 --- a/analysis_options_legacy.yaml +++ b/analysis_options_legacy.yaml @@ -7,6 +7,9 @@ analyzer: - '**/*.mocks.dart' # Mockito @GenerateMocks errors: always_require_non_null_named_parameters: false # not needed with nnbd + # TODO(https://github.com/flutter/flutter/issues/74381): + # Clean up existing unnecessary imports, and remove line to ignore. + unnecessary_import: ignore unnecessary_null_comparison: false # Turned as long as nnbd mix-mode is supported. linter: rules: From 3fd82b9208496317108e7d4a25df62dd0520b1f3 Mon Sep 17 00:00:00 2001 From: byunme Date: Thu, 9 Sep 2021 13:02:06 -0700 Subject: [PATCH 267/364] [video_player] interface: add support for content-uri based videos (android only) (#4307) --- .../video_player_platform_interface/CHANGELOG.md | 6 +++++- .../lib/method_channel_video_player.dart | 3 +++ .../lib/video_player_platform_interface.dart | 3 +++ .../video_player_platform_interface/pubspec.yaml | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/video_player/video_player_platform_interface/CHANGELOG.md b/packages/video_player/video_player_platform_interface/CHANGELOG.md index 24631513f800..b3da9c8924ef 100644 --- a/packages/video_player/video_player_platform_interface/CHANGELOG.md +++ b/packages/video_player/video_player_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.2.0 + +* Add `contentUri` to `DataSourceType`. + ## 4.1.0 * Add `httpHeaders` to `DataSource` @@ -29,7 +33,7 @@ ## 2.1.0 -* Add VideoPlayerOptions with audo mix mode +* Add VideoPlayerOptions with audio mix mode ## 2.0.2 diff --git a/packages/video_player/video_player_platform_interface/lib/method_channel_video_player.dart b/packages/video_player/video_player_platform_interface/lib/method_channel_video_player.dart index e92e87013d68..e01e5b8c072c 100644 --- a/packages/video_player/video_player_platform_interface/lib/method_channel_video_player.dart +++ b/packages/video_player/video_player_platform_interface/lib/method_channel_video_player.dart @@ -42,6 +42,9 @@ class MethodChannelVideoPlayer extends VideoPlayerPlatform { case DataSourceType.file: message.uri = dataSource.uri; break; + case DataSourceType.contentUri: + message.uri = dataSource.uri; + break; } TextureMessage response = await _api.create(message); diff --git a/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart b/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart index b2bff990401e..21ad972d8e06 100644 --- a/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart +++ b/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart @@ -196,6 +196,9 @@ enum DataSourceType { /// The video was loaded off of the local filesystem. file, + + /// The video is available via contentUri. Android only. + contentUri, } /// The file format of the given video. diff --git a/packages/video_player/video_player_platform_interface/pubspec.yaml b/packages/video_player/video_player_platform_interface/pubspec.yaml index 2a0ef10a9d2b..35b30793a20f 100644 --- a/packages/video_player/video_player_platform_interface/pubspec.yaml +++ b/packages/video_player/video_player_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 4.1.0 +version: 4.2.0 environment: sdk: ">=2.12.0 <3.0.0" From 1f472becf369e247dfd511ef0aa95c38ba75b0f1 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 9 Sep 2021 14:07:07 -0700 Subject: [PATCH 268/364] [camera] Bump minimum Flutter version and iOS deployment target (#4327) --- packages/camera/camera/CHANGELOG.md | 4 ++++ packages/camera/camera/README.md | 6 ++++-- .../camera/example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../camera/example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- packages/camera/camera/example/pubspec.yaml | 4 ++-- packages/camera/camera/ios/camera.podspec | 2 +- packages/camera/camera/pubspec.yaml | 6 +++--- 7 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index b141fab62595..ba2cb313b908 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.3 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 0.9.2+2 * Ensure that setting the exposure offset returns the new offset value on Android. diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index c66ed67af6cb..aa34273fb92c 100644 --- a/packages/camera/camera/README.md +++ b/packages/camera/camera/README.md @@ -6,7 +6,7 @@ A Flutter plugin for iOS and Android allowing access to the device cameras. *Note*: This plugin is still under development, and some APIs might not be available yet. We are working on a refactor which can be followed here: [issue](https://github.com/flutter/flutter/issues/31225) -## Features: +## Features * Display live camera preview in a widget. * Snapshots can be captured and saved to a file. @@ -19,7 +19,9 @@ First, add `camera` as a [dependency in your pubspec.yaml file](https://flutter. ### iOS -iOS 10.0 of higher is needed to use the camera plugin. If compiling for any version lower than 10.0 make sure to check the iOS version before using the camera plugin. For example, using the [device_info_plus](https://pub.dev/packages/device_info_plus) plugin. +The camera plugin functionality works on iOS 10.0 or higher. If compiling for any version lower than 10.0, +make sure to programmatically check the version of iOS running on the device before using any camera plugin features. +The [device_info_plus](https://pub.dev/packages/device_info_plus) plugin, for example, can be used to check the iOS version. Add two rows to the `ios/Runner/Info.plist`: diff --git a/packages/camera/camera/example/ios/Flutter/AppFrameworkInfo.plist b/packages/camera/camera/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/camera/camera/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/camera/camera/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index 5a622f17fc63..8520bb00fb2f 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -504,7 +504,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -554,7 +554,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/camera/camera/example/pubspec.yaml b/packages/camera/camera/example/pubspec.yaml index eb8995e2f354..1899835aca50 100644 --- a/packages/camera/camera/example/pubspec.yaml +++ b/packages/camera/camera/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the camera plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.22.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: camera: diff --git a/packages/camera/camera/ios/camera.podspec b/packages/camera/camera/ios/camera.podspec index 4f9955311fb9..4a142bd4589a 100644 --- a/packages/camera/camera/ios/camera.podspec +++ b/packages/camera/camera/ios/camera.podspec @@ -17,6 +17,6 @@ A Flutter plugin to use the camera from your Flutter app. s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' + s.platform = :ios, '9.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } end diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 582a830ebb4c..8d578bbdf065 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,11 +4,11 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.2+2 +version: 0.9.3 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 334d8e2e8db13f5d304f8a738f77809d86f27159 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 9 Sep 2021 23:27:05 +0200 Subject: [PATCH 269/364] [webview_flutter] Implementation of the webview_flutter_platform_interface package (#4302) --- .../AUTHORS | 67 +++ .../CHANGELOG.md | 3 + .../LICENSE | 25 + .../README.md | 23 + .../webview_method_channel.dart | 223 +++++++++ .../javascript_channel_registry.dart | 42 ++ .../platform_interface.dart | 8 + .../platform_interface/webview_platform.dart | 66 +++ .../webview_platform_callbacks_handler.dart | 32 ++ .../webview_platform_controller.dart | 177 +++++++ .../src/types/auto_media_playback_policy.dart | 22 + .../lib/src/types/creation_params.dart | 60 +++ .../lib/src/types/javascript_channel.dart | 39 ++ .../lib/src/types/javascript_message.dart | 14 + .../lib/src/types/javascript_mode.dart | 12 + .../lib/src/types/types.dart | 12 + .../lib/src/types/web_resource_error.dart | 57 +++ .../src/types/web_resource_error_type.dart | 66 +++ .../lib/src/types/web_settings.dart | 123 +++++ .../webview_flutter_platform_interface.dart | 7 + .../pubspec.yaml | 22 + .../webview_method_channel_test.dart | 457 ++++++++++++++++++ .../javascript_channel_registry_test.dart | 119 +++++ .../src/types/javascript_channel_test.dart | 48 ++ 24 files changed, 1724 insertions(+) create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/AUTHORS create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/LICENSE create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/README.md create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart create mode 100644 packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart diff --git a/packages/webview_flutter/webview_flutter_platform_interface/AUTHORS b/packages/webview_flutter/webview_flutter_platform_interface/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md new file mode 100644 index 000000000000..9e217a04e961 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +* Extracted platform interface from `webview_flutter`. \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_platform_interface/LICENSE b/packages/webview_flutter/webview_flutter_platform_interface/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webview_flutter/webview_flutter_platform_interface/README.md b/packages/webview_flutter/webview_flutter_platform_interface/README.md new file mode 100644 index 000000000000..31e57ab61597 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/README.md @@ -0,0 +1,23 @@ +# webview_flutter_platform_interface + +A common platform interface for the [`webview_flutter`](https://pub.dev/packages/webview_flutter) plugin. + +This interface allows platform-specific implementations of the `webview_flutter` +plugin, as well as the plugin itself, to ensure they are supporting the +same interface. + +# Usage + +To implement a new platform-specific implementation of `webview_flutter`, extend +[`WebviewPlatform`](lib/src/platform_interface/webview_platform.dart) with an implementation that performs the +platform-specific behavior, and when you register your plugin, set the default +`WebviewPlatform` by calling +`WebviewPlatform.setInstance(MyPlatformWebview())`. + +# Note on breaking changes + +Strongly prefer non-breaking changes (such as adding a method to the interface) +over breaking changes for this package. + +See https://flutter.dev/go/platform-interface-breaking-changes for a discussion +on why a less-clean interface is preferable to a breaking change. diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart new file mode 100644 index 000000000000..b467daf72a08 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart @@ -0,0 +1,223 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/services.dart'; + +import '../platform_interface/javascript_channel_registry.dart'; +import '../platform_interface/platform_interface.dart'; +import '../types/types.dart'; + +/// A [WebViewPlatformController] that uses a method channel to control the webview. +class MethodChannelWebViewPlatform implements WebViewPlatformController { + /// Constructs an instance that will listen for webviews broadcasting to the + /// given [id], using the given [WebViewPlatformCallbacksHandler]. + MethodChannelWebViewPlatform( + int id, + this._platformCallbacksHandler, + this._javascriptChannelRegistry, + ) : assert(_platformCallbacksHandler != null), + _channel = MethodChannel('plugins.flutter.io/webview_$id') { + _channel.setMethodCallHandler(_onMethodCall); + } + + final JavascriptChannelRegistry _javascriptChannelRegistry; + + final WebViewPlatformCallbacksHandler _platformCallbacksHandler; + + final MethodChannel _channel; + + static const MethodChannel _cookieManagerChannel = + MethodChannel('plugins.flutter.io/cookie_manager'); + + Future _onMethodCall(MethodCall call) async { + switch (call.method) { + case 'javascriptChannelMessage': + final String channel = call.arguments['channel']!; + final String message = call.arguments['message']!; + _javascriptChannelRegistry.onJavascriptChannelMessage(channel, message); + return true; + case 'navigationRequest': + return await _platformCallbacksHandler.onNavigationRequest( + url: call.arguments['url']!, + isForMainFrame: call.arguments['isForMainFrame']!, + ); + case 'onPageFinished': + _platformCallbacksHandler.onPageFinished(call.arguments['url']!); + return null; + case 'onProgress': + _platformCallbacksHandler.onProgress(call.arguments['progress']); + return null; + case 'onPageStarted': + _platformCallbacksHandler.onPageStarted(call.arguments['url']!); + return null; + case 'onWebResourceError': + _platformCallbacksHandler.onWebResourceError( + WebResourceError( + errorCode: call.arguments['errorCode']!, + description: call.arguments['description']!, + // iOS doesn't support `failingUrl`. + failingUrl: call.arguments['failingUrl'], + domain: call.arguments['domain'], + errorType: call.arguments['errorType'] == null + ? null + : WebResourceErrorType.values.firstWhere( + (WebResourceErrorType type) { + return type.toString() == + '$WebResourceErrorType.${call.arguments['errorType']}'; + }, + ), + ), + ); + return null; + } + + throw MissingPluginException( + '${call.method} was invoked but has no handler', + ); + } + + @override + Future loadUrl( + String url, + Map? headers, + ) async { + assert(url != null); + return _channel.invokeMethod('loadUrl', { + 'url': url, + 'headers': headers, + }); + } + + @override + Future currentUrl() => _channel.invokeMethod('currentUrl'); + + @override + Future canGoBack() => + _channel.invokeMethod("canGoBack").then((result) => result!); + + @override + Future canGoForward() => + _channel.invokeMethod("canGoForward").then((result) => result!); + + @override + Future goBack() => _channel.invokeMethod("goBack"); + + @override + Future goForward() => _channel.invokeMethod("goForward"); + + @override + Future reload() => _channel.invokeMethod("reload"); + + @override + Future clearCache() => _channel.invokeMethod("clearCache"); + + @override + Future updateSettings(WebSettings settings) async { + final Map updatesMap = _webSettingsToMap(settings); + if (updatesMap.isNotEmpty) { + await _channel.invokeMethod('updateSettings', updatesMap); + } + } + + @override + Future evaluateJavascript(String javascriptString) { + return _channel + .invokeMethod('evaluateJavascript', javascriptString) + .then((result) => result!); + } + + @override + Future addJavascriptChannels(Set javascriptChannelNames) { + return _channel.invokeMethod( + 'addJavascriptChannels', javascriptChannelNames.toList()); + } + + @override + Future removeJavascriptChannels(Set javascriptChannelNames) { + return _channel.invokeMethod( + 'removeJavascriptChannels', javascriptChannelNames.toList()); + } + + @override + Future getTitle() => _channel.invokeMethod("getTitle"); + + @override + Future scrollTo(int x, int y) { + return _channel.invokeMethod('scrollTo', { + 'x': x, + 'y': y, + }); + } + + @override + Future scrollBy(int x, int y) { + return _channel.invokeMethod('scrollBy', { + 'x': x, + 'y': y, + }); + } + + @override + Future getScrollX() => + _channel.invokeMethod("getScrollX").then((result) => result!); + + @override + Future getScrollY() => + _channel.invokeMethod("getScrollY").then((result) => result!); + + /// Method channel implementation for [WebViewPlatform.clearCookies]. + static Future clearCookies() { + return _cookieManagerChannel + .invokeMethod('clearCookies') + .then((dynamic result) => result!); + } + + static Map _webSettingsToMap(WebSettings? settings) { + final Map map = {}; + void _addIfNonNull(String key, dynamic value) { + if (value == null) { + return; + } + map[key] = value; + } + + void _addSettingIfPresent(String key, WebSetting setting) { + if (!setting.isPresent) { + return; + } + map[key] = setting.value; + } + + _addIfNonNull('jsMode', settings!.javascriptMode?.index); + _addIfNonNull('hasNavigationDelegate', settings.hasNavigationDelegate); + _addIfNonNull('hasProgressTracking', settings.hasProgressTracking); + _addIfNonNull('debuggingEnabled', settings.debuggingEnabled); + _addIfNonNull( + 'gestureNavigationEnabled', settings.gestureNavigationEnabled); + _addIfNonNull( + 'allowsInlineMediaPlayback', settings.allowsInlineMediaPlayback); + _addSettingIfPresent('userAgent', settings.userAgent); + return map; + } + + /// Converts a [CreationParams] object to a map as expected by `platform_views` channel. + /// + /// This is used for the `creationParams` argument of the platform views created by + /// [AndroidWebViewBuilder] and [CupertinoWebViewBuilder]. + static Map creationParamsToMap( + CreationParams creationParams, { + bool usesHybridComposition = false, + }) { + return { + 'initialUrl': creationParams.initialUrl, + 'settings': _webSettingsToMap(creationParams.webSettings), + 'javascriptChannelNames': creationParams.javascriptChannelNames.toList(), + 'userAgent': creationParams.userAgent, + 'autoMediaPlaybackPolicy': creationParams.autoMediaPlaybackPolicy.index, + 'usesHybridComposition': usesHybridComposition, + }; + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart new file mode 100644 index 000000000000..142d8eb00950 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart @@ -0,0 +1,42 @@ +// Copyright 2013 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 '../types/javascript_channel.dart'; +import '../types/javascript_message.dart'; + +/// Utility class for managing named JavaScript channels and forwarding incoming +/// messages on the correct channel. +class JavascriptChannelRegistry { + /// Constructs a [JavascriptChannelRegistry] initializing it with the given + /// set of [JavascriptChannel]s. + JavascriptChannelRegistry(Set? channels) { + updateJavascriptChannelsFromSet(channels); + } + + /// Maps a channel name to a channel. + final Map channels = {}; + + /// Invoked when a JavaScript channel message is received. + void onJavascriptChannelMessage(String channel, String message) { + final JavascriptChannel? javascriptChannel = channels[channel]; + + if (javascriptChannel == null) { + throw ArgumentError('No channel registered with name $channel.'); + } + + javascriptChannel.onMessageReceived(JavascriptMessage(message)); + } + + /// Updates the set of [JavascriptChannel]s with the new set. + void updateJavascriptChannelsFromSet(Set? channels) { + this.channels.clear(); + if (channels == null) { + return; + } + + for (final JavascriptChannel channel in channels) { + this.channels[channel.name] = channel; + } + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart new file mode 100644 index 000000000000..43f967fb13b0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart @@ -0,0 +1,8 @@ +// Copyright 2013 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. + +export 'javascript_channel_registry.dart'; +export 'webview_platform.dart'; +export 'webview_platform_callbacks_handler.dart'; +export 'webview_platform_controller.dart'; diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart new file mode 100644 index 000000000000..4732f54d6456 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart @@ -0,0 +1,66 @@ +// Copyright 2013 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 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/src/platform_interface/javascript_channel_registry.dart'; + +import '../types/types.dart'; +import 'webview_platform_callbacks_handler.dart'; +import 'webview_platform_controller.dart'; + +/// Signature for callbacks reporting that a [WebViewPlatformController] was created. +/// +/// See also the `onWebViewPlatformCreated` argument for [WebViewPlatform.build]. +typedef WebViewPlatformCreatedCallback = void Function( + WebViewPlatformController? webViewPlatformController); + +/// Interface for a platform implementation of a WebView. +/// +/// [WebView.platform] controls the builder that is used by [WebView]. +/// [AndroidWebViewPlatform] and [CupertinoWebViewPlatform] are the default implementations +/// for Android and iOS respectively. +abstract class WebViewPlatform { + /// Builds a new WebView. + /// + /// Returns a Widget tree that embeds the created webview. + /// + /// `creationParams` are the initial parameters used to setup the webview. + /// + /// `webViewPlatformHandler` will be used for handling callbacks that are made by the created + /// [WebViewPlatformController]. + /// + /// `onWebViewPlatformCreated` will be invoked after the platform specific [WebViewPlatformController] + /// implementation is created with the [WebViewPlatformController] instance as a parameter. + /// + /// `gestureRecognizers` specifies which gestures should be consumed by the web view. + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// When `gestureRecognizers` is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + /// + /// `webViewPlatformHandler` must not be null. + Widget build({ + required BuildContext context, + // TODO(amirh): convert this to be the actual parameters. + // I'm starting without it as the PR is starting to become pretty big. + // I'll followup with the conversion PR. + required CreationParams creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }); + + /// Clears all cookies for all [WebView] instances. + /// + /// Returns true if cookies were present before clearing, else false. + Future clearCookies() { + throw UnimplementedError( + "WebView clearCookies is not implemented on the current platform"); + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart new file mode 100644 index 000000000000..44dae2ece434 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart @@ -0,0 +1,32 @@ +// Copyright 2013 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:async'; + +import '../types/types.dart'; + +/// Interface for callbacks made by [WebViewPlatformController]. +/// +/// The webview plugin implements this class, and passes an instance to the [WebViewPlatformController]. +/// [WebViewPlatformController] is notifying this handler on events that happened on the platform's webview. +abstract class WebViewPlatformCallbacksHandler { + /// Invoked by [WebViewPlatformController] when a navigation request is pending. + /// + /// If true is returned the navigation is allowed, otherwise it is blocked. + FutureOr onNavigationRequest( + {required String url, required bool isForMainFrame}); + + /// Invoked by [WebViewPlatformController] when a page has started loading. + void onPageStarted(String url); + + /// Invoked by [WebViewPlatformController] when a page has finished loading. + void onPageFinished(String url); + + /// Invoked by [WebViewPlatformController] when a page is loading. + /// /// Only works when [WebSettings.hasProgressTracking] is set to `true`. + void onProgress(int progress); + + /// Report web resource loading error to the host application. + void onWebResourceError(WebResourceError error); +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart new file mode 100644 index 000000000000..319ca7e7a845 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart @@ -0,0 +1,177 @@ +// Copyright 2013 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 '../types/types.dart'; +import 'webview_platform_callbacks_handler.dart'; + +/// Interface for talking to the webview's platform implementation. +/// +/// An instance implementing this interface is passed to the `onWebViewPlatformCreated` callback that is +/// passed to [WebViewPlatformBuilder#onWebViewPlatformCreated]. +/// +/// Platform implementations that live in a separate package should extend this class rather than +/// implement it as webview_flutter does not consider newly added methods to be breaking changes. +/// Extending this class (using `extends`) ensures that the subclass will get the default +/// implementation, while platform implementations that `implements` this interface will be broken +/// by newly added [WebViewPlatformController] methods. +abstract class WebViewPlatformController { + /// Creates a new WebViewPlatform. + /// + /// Callbacks made by the WebView will be delegated to `handler`. + /// + /// The `handler` parameter must not be null. + WebViewPlatformController(WebViewPlatformCallbacksHandler handler); + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, + Map? headers, + ) { + throw UnimplementedError( + "WebView loadUrl is not implemented on the current platform"); + } + + /// Updates the webview settings. + /// + /// Any non null field in `settings` will be set as the new setting value. + /// All null fields in `settings` are ignored. + Future updateSettings(WebSettings setting) { + throw UnimplementedError( + "WebView updateSettings is not implemented on the current platform"); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If no URL was ever loaded, returns `null`. + Future currentUrl() { + throw UnimplementedError( + "WebView currentUrl is not implemented on the current platform"); + } + + /// Checks whether there's a back history item. + Future canGoBack() { + throw UnimplementedError( + "WebView canGoBack is not implemented on the current platform"); + } + + /// Checks whether there's a forward history item. + Future canGoForward() { + throw UnimplementedError( + "WebView canGoForward is not implemented on the current platform"); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + throw UnimplementedError( + "WebView goBack is not implemented on the current platform"); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + throw UnimplementedError( + "WebView goForward is not implemented on the current platform"); + } + + /// Reloads the current URL. + Future reload() { + throw UnimplementedError( + "WebView reload is not implemented on the current platform"); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + Future clearCache() { + throw UnimplementedError( + "WebView clearCache is not implemented on the current platform"); + } + + /// Evaluates a JavaScript expression in the context of the current page. + /// + /// The Future completes with an error if a JavaScript error occurred, or if the type of the + /// evaluated expression is not supported(e.g on iOS not all non primitive type can be evaluated). + Future evaluateJavascript(String javascriptString) { + throw UnimplementedError( + "WebView evaluateJavascript is not implemented on the current platform"); + } + + /// Adds new JavaScript channels to the set of enabled channels. + /// + /// For each value in this list the platform's webview should make sure that a corresponding + /// property with a postMessage method is set on `window`. For example for a JavaScript channel + /// named `Foo` it should be possible for JavaScript code executing in the webview to do + /// + /// ```javascript + /// Foo.postMessage('hello'); + /// ``` + /// + /// See also: [CreationParams.javascriptChannelNames]. + Future addJavascriptChannels(Set javascriptChannelNames) { + throw UnimplementedError( + "WebView addJavascriptChannels is not implemented on the current platform"); + } + + /// Removes JavaScript channel names from the set of enabled channels. + /// + /// This disables channels that were previously enabled by [addJavaScriptChannels] or through + /// [CreationParams.javascriptChannelNames]. + Future removeJavascriptChannels(Set javascriptChannelNames) { + throw UnimplementedError( + "WebView removeJavascriptChannels is not implemented on the current platform"); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + throw UnimplementedError( + "WebView getTitle is not implemented on the current platform"); + } + + /// Set the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the position to scroll to in WebView pixels. + Future scrollTo(int x, int y) { + throw UnimplementedError( + "WebView scrollTo is not implemented on the current platform"); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by. + Future scrollBy(int x, int y) { + throw UnimplementedError( + "WebView scrollBy is not implemented on the current platform"); + } + + /// Return the horizontal scroll position of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + throw UnimplementedError( + "WebView getScrollX is not implemented on the current platform"); + } + + /// Return the vertical scroll position of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + throw UnimplementedError( + "WebView getScrollY is not implemented on the current platform"); + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart new file mode 100644 index 000000000000..7d6927ac7957 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart @@ -0,0 +1,22 @@ +// Copyright 2013 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. + +/// Specifies possible restrictions on automatic media playback. +/// +/// This is typically used in [WebView.initialMediaPlaybackPolicy]. +// The method channel implementation is marshalling this enum to the value's index, so the order +// is important. +enum AutoMediaPlaybackPolicy { + /// Starting any kind of media playback requires a user action. + /// + /// For example: JavaScript code cannot start playing media unless the code was executed + /// as a result of a user action (like a touch event). + require_user_action_for_all_media_types, + + /// Starting any kind of media playback is always allowed. + /// + /// For example: JavaScript code that's triggered when the page is loaded can start playing + /// video or audio. + always_allow, +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart new file mode 100644 index 000000000000..f213e976ad84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart @@ -0,0 +1,60 @@ +// Copyright 2013 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 'auto_media_playback_policy.dart'; +import 'web_settings.dart'; + +/// Configuration to use when creating a new [WebViewPlatformController]. +/// +/// The `autoMediaPlaybackPolicy` parameter must not be null. +class CreationParams { + /// Constructs an instance to use when creating a new + /// [WebViewPlatformController]. + /// + /// The `autoMediaPlaybackPolicy` parameter must not be null. + CreationParams({ + this.initialUrl, + this.webSettings, + this.javascriptChannelNames = const {}, + this.userAgent, + this.autoMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + }) : assert(autoMediaPlaybackPolicy != null); + + /// The initialUrl to load in the webview. + /// + /// When null the webview will be created without loading any page. + final String? initialUrl; + + /// The initial [WebSettings] for the new webview. + /// + /// This can later be updated with [WebViewPlatformController.updateSettings]. + final WebSettings? webSettings; + + /// The initial set of JavaScript channels that are configured for this webview. + /// + /// For each value in this set the platform's webview should make sure that a corresponding + /// property with a postMessage method is set on `window`. For example for a JavaScript channel + /// named `Foo` it should be possible for JavaScript code executing in the webview to do + /// + /// ```javascript + /// Foo.postMessage('hello'); + /// ``` + // TODO(amirh): describe what should happen when postMessage is called once that code is migrated + // to PlatformWebView. + final Set javascriptChannelNames; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + final String? userAgent; + + /// Which restrictions apply on automatic media playback. + final AutoMediaPlaybackPolicy autoMediaPlaybackPolicy; + + @override + String toString() { + return '$runtimeType(initialUrl: $initialUrl, settings: $webSettings, javascriptChannelNames: $javascriptChannelNames, UserAgent: $userAgent)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart new file mode 100644 index 000000000000..8b31f5b6061e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart @@ -0,0 +1,39 @@ +// Copyright 2013 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 'javascript_message.dart'; + +/// Callback type for handling messages sent from Javascript running in a web view. +typedef void JavascriptMessageHandler(JavascriptMessage message); + +final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9_]*\$'); + +/// A named channel for receiving messaged from JavaScript code running inside a web view. +class JavascriptChannel { + /// Constructs a Javascript channel. + /// + /// The parameters `name` and `onMessageReceived` must not be null. + JavascriptChannel({ + required this.name, + required this.onMessageReceived, + }) : assert(name != null), + assert(onMessageReceived != null), + assert(_validChannelNames.hasMatch(name)); + + /// The channel's name. + /// + /// Passing this channel object as part of a [WebView.javascriptChannels] adds a channel object to + /// the Javascript window object's property named `name`. + /// + /// The name must start with a letter or underscore(_), followed by any combination of those + /// characters plus digits. + /// + /// Note that any JavaScript existing `window` property with this name will be overriden. + /// + /// See also [WebView.javascriptChannels] for more details on the channel registration mechanism. + final String name; + + /// A callback that's invoked when a message is received through the channel. + final JavascriptMessageHandler onMessageReceived; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart new file mode 100644 index 000000000000..8d080452c54a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +/// A message that was sent by JavaScript code running in a [WebView]. +class JavascriptMessage { + /// Constructs a JavaScript message object. + /// + /// The `message` parameter must not be null. + const JavascriptMessage(this.message) : assert(message != null); + + /// The contents of the message that was sent by the JavaScript code. + final String message; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart new file mode 100644 index 000000000000..53d049175907 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart @@ -0,0 +1,12 @@ +// Copyright 2013 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. + +/// Describes the state of JavaScript support in a given web view. +enum JavascriptMode { + /// JavaScript execution is disabled. + disabled, + + /// JavaScript execution is not restricted. + unrestricted, +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart new file mode 100644 index 000000000000..b1a9b9b9daa8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart @@ -0,0 +1,12 @@ +// Copyright 2013 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. + +export 'auto_media_playback_policy.dart'; +export 'creation_params.dart'; +export 'javascript_channel.dart'; +export 'javascript_message.dart'; +export 'javascript_mode.dart'; +export 'web_resource_error.dart'; +export 'web_resource_error_type.dart'; +export 'web_settings.dart'; diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart new file mode 100644 index 000000000000..b61671f0ac45 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart @@ -0,0 +1,57 @@ +// Copyright 2013 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 'web_resource_error_type.dart'; + +/// Error returned in `WebView.onWebResourceError` when a web resource loading error has occurred. +class WebResourceError { + /// Creates a new [WebResourceError] + /// + /// A user should not need to instantiate this class, but will receive one in + /// [WebResourceErrorCallback]. + WebResourceError({ + required this.errorCode, + required this.description, + this.domain, + this.errorType, + this.failingUrl, + }) : assert(errorCode != null), + assert(description != null); + + /// Raw code of the error from the respective platform. + /// + /// On Android, the error code will be a constant from a + /// [WebViewClient](https://developer.android.com/reference/android/webkit/WebViewClient#summary) and + /// will have a corresponding [errorType]. + /// + /// On iOS, the error code will be a constant from `NSError.code` in + /// Objective-C. See + /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html + /// for more information on error handling on iOS. Some possible error codes + /// can be found at https://developer.apple.com/documentation/webkit/wkerrorcode?language=objc. + final int errorCode; + + /// The domain of where to find the error code. + /// + /// This field is only available on iOS and represents a "domain" from where + /// the [errorCode] is from. This value is taken directly from an `NSError` + /// in Objective-C. See + /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html + /// for more information on error handling on iOS. + final String? domain; + + /// Description of the error that can be used to communicate the problem to the user. + final String description; + + /// The type this error can be categorized as. + /// + /// This will never be `null` on Android, but can be `null` on iOS. + final WebResourceErrorType? errorType; + + /// Gets the URL for which the resource request was made. + /// + /// This value is not provided on iOS. Alternatively, you can keep track of + /// the last values provided to [WebViewPlatformController.loadUrl]. + final String? failingUrl; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart new file mode 100644 index 000000000000..a45816df8323 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart @@ -0,0 +1,66 @@ +// Copyright 2013 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. + +/// Possible error type categorizations used by [WebResourceError]. +enum WebResourceErrorType { + /// User authentication failed on server. + authentication, + + /// Malformed URL. + badUrl, + + /// Failed to connect to the server. + connect, + + /// Failed to perform SSL handshake. + failedSslHandshake, + + /// Generic file error. + file, + + /// File not found. + fileNotFound, + + /// Server or proxy hostname lookup failed. + hostLookup, + + /// Failed to read or write to the server. + io, + + /// User authentication failed on proxy. + proxyAuthentication, + + /// Too many redirects. + redirectLoop, + + /// Connection timed out. + timeout, + + /// Too many requests during this load. + tooManyRequests, + + /// Generic error. + unknown, + + /// Resource load was canceled by Safe Browsing. + unsafeResource, + + /// Unsupported authentication scheme (not basic or digest). + unsupportedAuthScheme, + + /// Unsupported URI scheme. + unsupportedScheme, + + /// The web content process was terminated. + webContentProcessTerminated, + + /// The web view was invalidated. + webViewInvalidated, + + /// A JavaScript exception occurred. + javaScriptExceptionOccurred, + + /// The result of JavaScript execution could not be returned. + javaScriptResultTypeIsUnsupported, +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart new file mode 100644 index 000000000000..48b2de9c1ca0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart @@ -0,0 +1,123 @@ +// Copyright 2013 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 'package:flutter/widgets.dart'; + +import 'javascript_mode.dart'; + +/// A single setting for configuring a WebViewPlatform which may be absent. +class WebSetting { + /// Constructs an absent setting instance. + /// + /// The [isPresent] field for the instance will be false. + /// + /// Accessing [value] for an absent instance will throw. + WebSetting.absent() + : _value = null, + isPresent = false; + + /// Constructs a setting of the given `value`. + /// + /// The [isPresent] field for the instance will be true. + WebSetting.of(T value) + : _value = value, + isPresent = true; + + final T? _value; + + /// The setting's value. + /// + /// Throws if [WebSetting.isPresent] is false. + T get value { + if (!isPresent) { + throw StateError('Cannot access a value of an absent WebSetting'); + } + assert(isPresent); + // The intention of this getter is to return T whether it is nullable or + // not whereas _value is of type T? since _value can be null even when + // T is not nullable (when isPresent == false). + // + // We promote _value to T using `as T` instead of `!` operator to handle + // the case when _value is legitimately null (and T is a nullable type). + // `!` operator would always throw if _value is null. + return _value as T; + } + + /// True when this web setting instance contains a value. + /// + /// When false the [WebSetting.value] getter throws. + final bool isPresent; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + final WebSetting typedOther = other as WebSetting; + return typedOther.isPresent == isPresent && typedOther._value == _value; + } + + @override + int get hashCode => hashValues(_value, isPresent); +} + +/// Settings for configuring a WebViewPlatform. +/// +/// Initial settings are passed as part of [CreationParams], settings updates are sent with +/// [WebViewPlatform#updateSettings]. +/// +/// The `userAgent` parameter must not be null. +class WebSettings { + /// Construct an instance with initial settings. Future setting changes can be + /// sent with [WebviewPlatform#updateSettings]. + /// + /// The `userAgent` parameter must not be null. + WebSettings({ + this.javascriptMode, + this.hasNavigationDelegate, + this.hasProgressTracking, + this.debuggingEnabled, + this.gestureNavigationEnabled, + this.allowsInlineMediaPlayback, + required this.userAgent, + }) : assert(userAgent != null); + + /// The JavaScript execution mode to be used by the webview. + final JavascriptMode? javascriptMode; + + /// Whether the [WebView] has a [NavigationDelegate] set. + final bool? hasNavigationDelegate; + + /// Whether the [WebView] should track page loading progress. + /// See also: [WebViewPlatformCallbacksHandler.onProgress] to get the progress. + final bool? hasProgressTracking; + + /// Whether to enable the platform's webview content debugging tools. + /// + /// See also: [WebView.debuggingEnabled]. + final bool? debuggingEnabled; + + /// Whether to play HTML5 videos inline or use the native full-screen controller on iOS. + /// + /// This will have no effect on Android. + final bool? allowsInlineMediaPlayback; + + /// The value used for the HTTP `User-Agent:` request header. + /// + /// If [userAgent.value] is null the platform's default user agent should be used. + /// + /// An absent value ([userAgent.isPresent] is false) represents no change to this setting from the + /// last time it was set. + /// + /// See also [WebView.userAgent]. + final WebSetting userAgent; + + /// Whether to allow swipe based navigation in iOS. + /// + /// See also: [WebView.gestureNavigationEnabled] + final bool? gestureNavigationEnabled; + + @override + String toString() { + return 'WebSettings(javascriptMode: $javascriptMode, hasNavigationDelegate: $hasNavigationDelegate, hasProgressTracking: $hasProgressTracking, debuggingEnabled: $debuggingEnabled, gestureNavigationEnabled: $gestureNavigationEnabled, userAgent: $userAgent, allowsInlineMediaPlayback: $allowsInlineMediaPlayback)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart new file mode 100644 index 000000000000..b508989ed978 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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. + +export 'src/platform_interface/platform_interface.dart'; +export 'src/types/types.dart'; +export 'src/method_channel/webview_method_channel.dart'; diff --git a/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml new file mode 100644 index 000000000000..bf43c265d77a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml @@ -0,0 +1,22 @@ +name: webview_flutter_platform_interface +description: A common platform interface for the webview_flutter plugin. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_platform_interface +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview_flutter%22 +# NOTE: We strongly prefer non-breaking changes, even at the expense of a +# less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes +version: 1.0.0 + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^5.0.0 + pedantic: ^1.10.0 \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart new file mode 100644 index 000000000000..2f845eaa4999 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart @@ -0,0 +1,457 @@ +// Copyright 2013 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 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:webview_flutter_platform_interface/src/method_channel/webview_method_channel.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Tests on `plugin.flutter.io/webview_` channel', () { + const int channelId = 1; + const MethodChannel channel = + MethodChannel('plugins.flutter.io/webview_$channelId'); + final WebViewPlatformCallbacksHandler callbacksHandler = + MockWebViewPlatformCallbacksHandler(); + final JavascriptChannelRegistry javascriptChannelRegistry = + MockJavascriptChannelRegistry(); + + final List log = []; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + + switch (methodCall.method) { + case 'currentUrl': + return 'https://test.url'; + case 'canGoBack': + case 'canGoForward': + return true; + case 'evaluateJavascript': + return methodCall.arguments as String; + case 'getScrollX': + return 10; + case 'getScrollY': + return 20; + } + + // Return null explicitly instead of relying on the implicit null + // returned by the method channel if no return statement is specified. + return null; + }); + + final MethodChannelWebViewPlatform webViewPlatform = + MethodChannelWebViewPlatform( + channelId, + callbacksHandler, + javascriptChannelRegistry, + ); + + tearDown(() { + log.clear(); + }); + + test('loadUrl with headers', () async { + await webViewPlatform.loadUrl( + 'https://test.url', + const { + 'Content-Type': 'text/plain', + 'Accept': 'text/html', + }, + ); + + expect( + log, + [ + isMethodCall( + 'loadUrl', + arguments: { + 'url': 'https://test.url', + 'headers': { + 'Content-Type': 'text/plain', + 'Accept': 'text/html', + }, + }, + ), + ], + ); + }); + + test('loadUrl without headers', () async { + await webViewPlatform.loadUrl( + 'https://test.url', + null, + ); + + expect( + log, + [ + isMethodCall( + 'loadUrl', + arguments: { + 'url': 'https://test.url', + 'headers': null, + }, + ), + ], + ); + }); + + test('currentUrl', () async { + final String? currentUrl = await webViewPlatform.currentUrl(); + + expect(currentUrl, 'https://test.url'); + expect( + log, + [ + isMethodCall( + 'currentUrl', + arguments: null, + ), + ], + ); + }); + + test('canGoBack', () async { + final bool canGoBack = await webViewPlatform.canGoBack(); + + expect(canGoBack, true); + expect( + log, + [ + isMethodCall( + 'canGoBack', + arguments: null, + ), + ], + ); + }); + + test('canGoForward', () async { + final bool canGoForward = await webViewPlatform.canGoForward(); + + expect(canGoForward, true); + expect( + log, + [ + isMethodCall( + 'canGoForward', + arguments: null, + ), + ], + ); + }); + + test('goBack', () async { + await webViewPlatform.goBack(); + + expect( + log, + [ + isMethodCall( + 'goBack', + arguments: null, + ), + ], + ); + }); + + test('goForward', () async { + await webViewPlatform.goForward(); + + expect( + log, + [ + isMethodCall( + 'goForward', + arguments: null, + ), + ], + ); + }); + + test('reload', () async { + await webViewPlatform.reload(); + + expect( + log, + [ + isMethodCall( + 'reload', + arguments: null, + ), + ], + ); + }); + + test('clearCache', () async { + await webViewPlatform.clearCache(); + + expect( + log, + [ + isMethodCall( + 'clearCache', + arguments: null, + ), + ], + ); + }); + + test('updateSettings', () async { + final WebSettings settings = + WebSettings(userAgent: WebSetting.of('Dart Test')); + await webViewPlatform.updateSettings(settings); + + expect( + log, + [ + isMethodCall( + 'updateSettings', + arguments: { + 'userAgent': 'Dart Test', + }, + ), + ], + ); + }); + + test('updateSettings all parameters', () async { + final WebSettings settings = WebSettings( + userAgent: WebSetting.of('Dart Test'), + javascriptMode: JavascriptMode.disabled, + hasNavigationDelegate: true, + hasProgressTracking: true, + debuggingEnabled: true, + gestureNavigationEnabled: true, + allowsInlineMediaPlayback: true, + ); + await webViewPlatform.updateSettings(settings); + + expect( + log, + [ + isMethodCall( + 'updateSettings', + arguments: { + 'userAgent': 'Dart Test', + 'jsMode': 0, + 'hasNavigationDelegate': true, + 'hasProgressTracking': true, + 'debuggingEnabled': true, + 'gestureNavigationEnabled': true, + 'allowsInlineMediaPlayback': true, + }, + ), + ], + ); + }); + + test('updateSettings without settings', () async { + final WebSettings settings = + WebSettings(userAgent: WebSetting.absent()); + await webViewPlatform.updateSettings(settings); + + expect( + log.isEmpty, + true, + ); + }); + + test('evaluateJavascript', () async { + final String evaluateJavascript = + await webViewPlatform.evaluateJavascript( + 'This simulates some Javascript code.', + ); + + expect('This simulates some Javascript code.', evaluateJavascript); + expect( + log, + [ + isMethodCall( + 'evaluateJavascript', + arguments: 'This simulates some Javascript code.', + ), + ], + ); + }); + + test('addJavascriptChannels', () async { + final Set channels = {'channel one', 'channel two'}; + await webViewPlatform.addJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'addJavascriptChannels', + arguments: [ + 'channel one', + 'channel two', + ], + ), + ]); + }); + + test('addJavascriptChannels without channels', () async { + final Set channels = {}; + await webViewPlatform.addJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'addJavascriptChannels', + arguments: [], + ), + ]); + }); + + test('removeJavascriptChannels', () async { + final Set channels = {'channel one', 'channel two'}; + await webViewPlatform.removeJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'removeJavascriptChannels', + arguments: [ + 'channel one', + 'channel two', + ], + ), + ]); + }); + + test('removeJavascriptChannels without channels', () async { + final Set channels = {}; + await webViewPlatform.removeJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'removeJavascriptChannels', + arguments: [], + ), + ]); + }); + + test('getTitle', () async { + final String? title = await webViewPlatform.getTitle(); + + expect(title, null); + expect( + log, + [ + isMethodCall('getTitle', arguments: null), + ], + ); + }); + + test('scrollTo', () async { + await webViewPlatform.scrollTo(10, 20); + + expect( + log, + [ + isMethodCall( + 'scrollTo', + arguments: { + 'x': 10, + 'y': 20, + }, + ), + ], + ); + }); + + test('scrollBy', () async { + await webViewPlatform.scrollBy(10, 20); + + expect( + log, + [ + isMethodCall( + 'scrollBy', + arguments: { + 'x': 10, + 'y': 20, + }, + ), + ], + ); + }); + + test('getScrollX', () async { + final int x = await webViewPlatform.getScrollX(); + + expect(x, 10); + expect( + log, + [ + isMethodCall( + 'getScrollX', + arguments: null, + ), + ], + ); + }); + + test('getScrollY', () async { + final int y = await webViewPlatform.getScrollY(); + + expect(y, 20); + expect( + log, + [ + isMethodCall( + 'getScrollY', + arguments: null, + ), + ], + ); + }); + }); + + group('Tests on `plugins.flutter.io/cookie_manager` channel', () { + const MethodChannel cookieChannel = + MethodChannel('plugins.flutter.io/cookie_manager'); + + final List log = []; + cookieChannel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + + if (methodCall.method == 'clearCookies') { + return true; + } + + // Return null explicitly instead of relying on the implicit null + // returned by the method channel if no return statement is specified. + return null; + }); + + tearDown(() { + log.clear(); + }); + + test('clearCookies', () async { + final bool clearCookies = + await MethodChannelWebViewPlatform.clearCookies(); + + expect(clearCookies, true); + expect( + log, + [ + isMethodCall( + 'clearCookies', + arguments: null, + ), + ], + ); + }); + }); +} + +class MockWebViewPlatformCallbacksHandler extends Mock + implements WebViewPlatformCallbacksHandler {} + +class MockJavascriptChannelRegistry extends Mock + implements JavascriptChannelRegistry {} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart new file mode 100644 index 000000000000..55d0e1e13bd1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart @@ -0,0 +1,119 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/src/types/javascript_channel.dart'; +import 'package:webview_flutter_platform_interface/src/types/types.dart'; +import 'package:webview_flutter_platform_interface/src/platform_interface/javascript_channel_registry.dart'; + +void main() { + final Map _log = {}; + final Set _channels = { + JavascriptChannel( + name: 'js_channel_1', + onMessageReceived: (JavascriptMessage message) => + _log['js_channel_1'] = message.message, + ), + JavascriptChannel( + name: 'js_channel_2', + onMessageReceived: (JavascriptMessage message) => + _log['js_channel_2'] = message.message, + ), + JavascriptChannel( + name: 'js_channel_3', + onMessageReceived: (JavascriptMessage message) => + _log['js_channel_3'] = message.message, + ), + }; + + tearDown(() { + _log.clear(); + }); + + test('ctor should initialize with channels.', () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect(registry.channels.length, 3); + for (final JavascriptChannel channel in _channels) { + expect(registry.channels[channel.name], channel); + } + }); + + test('onJavascriptChannelMessage should forward message on correct channel.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + registry.onJavascriptChannelMessage( + 'js_channel_2', + 'test message on channel 2', + ); + + expect( + _log, + containsPair( + 'js_channel_2', + 'test message on channel 2', + )); + }); + + test( + 'onJavascriptChannelMessage should throw ArgumentError when message arrives on non-existing channel.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect( + () => registry.onJavascriptChannelMessage( + 'js_channel_4', + 'test message on channel 2', + ), + throwsA( + isA().having((ArgumentError error) => error.message, + 'message', 'No channel registered with name js_channel_4.'), + )); + }); + + test( + 'updateJavascriptChannelsFromSet should clear all channels when null is supplied.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect(registry.channels.length, 3); + + registry.updateJavascriptChannelsFromSet(null); + + expect(registry.channels, isEmpty); + }); + + test('updateJavascriptChannelsFromSet should update registry with new set.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect(registry.channels.length, 3); + + final Set newChannels = { + JavascriptChannel( + name: 'new_js_channel_1', + onMessageReceived: (JavascriptMessage message) => + _log['new_js_channel_1'] = message.message, + ), + JavascriptChannel( + name: 'new_js_channel_2', + onMessageReceived: (JavascriptMessage message) => + _log['new_js_channel_2'] = message.message, + ), + }; + + registry.updateJavascriptChannelsFromSet(newChannels); + + expect(registry.channels.length, 2); + for (final JavascriptChannel channel in newChannels) { + expect(registry.channels[channel.name], channel); + } + }); +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart new file mode 100644 index 000000000000..f481edda1edd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart @@ -0,0 +1,48 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/src/types/javascript_channel.dart'; + +void main() { + final List _validChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_'.split(''); + final List _commonInvalidChars = + r'`~!@#$%^&*()-=+[]{}\|"' ':;/?<>,. '.split(''); + final List _digits = List.generate(10, (int index) => index++); + + test( + 'ctor should create JavascriptChannel when name starts with a valid character followed by a number.', + () { + for (final String char in _validChars) { + for (final int digit in _digits) { + final JavascriptChannel channel = + JavascriptChannel(name: '$char$digit', onMessageReceived: (_) {}); + + expect(channel.name, '$char$digit'); + } + } + }); + + test('ctor should assert when channel name starts with a number.', () { + for (final int i in _digits) { + expect( + () => JavascriptChannel(name: '$i', onMessageReceived: (_) {}), + throwsAssertionError, + ); + } + }); + + test('ctor should assert when channel contains invalid char.', () { + for (final String validChar in _validChars) { + for (final String invalidChar in _commonInvalidChars) { + expect( + () => JavascriptChannel( + name: validChar + invalidChar, onMessageReceived: (_) {}), + throwsAssertionError, + ); + } + } + }); +} From d5b65742487f64166a73f116b925ce800c45dcd7 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 9 Sep 2021 15:30:32 -0700 Subject: [PATCH 270/364] [video_player] add support for content-uri based videos (#4330) --- .../video_player/video_player/CHANGELOG.md | 4 ++++ .../video_player/lib/video_player.dart | 21 +++++++++++++++++++ .../video_player/video_player/pubspec.yaml | 4 ++-- .../video_player/test/video_player_test.dart | 9 ++++++++ .../video_player_web/CHANGELOG.md | 4 ++++ .../video_player_web_test.dart | 11 ++++++++++ .../lib/video_player_web.dart | 3 +++ .../video_player_web/pubspec.yaml | 4 ++-- 8 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 9cb642a4db56..a82455231ecd 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.0 + +* Add `contentUri` based VideoPlayerController. + ## 2.1.15 * Ensured seekTo isn't called before video player is initialized. Fixes [#89259](https://github.com/flutter/flutter/issues/89259). diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index b4c4b2b2a311..685563ae12c3 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -222,6 +222,21 @@ class VideoPlayerController extends ValueNotifier { httpHeaders = const {}, super(VideoPlayerValue(duration: Duration.zero)); + /// Constructs a [VideoPlayerController] playing a video from a contentUri. + /// + /// This will load the video from the input content-URI. + /// This is supported on Android only. + VideoPlayerController.contentUri(Uri contentUri, + {this.closedCaptionFile, this.videoPlayerOptions}) + : assert(defaultTargetPlatform == TargetPlatform.android, + 'VideoPlayerController.contentUri is only supported on Android.'), + dataSource = contentUri.toString(), + dataSourceType = DataSourceType.contentUri, + package = null, + formatHint = null, + httpHeaders = const {}, + super(VideoPlayerValue(duration: Duration.zero)); + /// The URI to the video file. This will be in different formats depending on /// the [DataSourceType] of the original video. final String dataSource; @@ -298,6 +313,12 @@ class VideoPlayerController extends ValueNotifier { uri: dataSource, ); break; + case DataSourceType.contentUri: + dataSourceDescription = DataSource( + sourceType: DataSourceType.contentUri, + uri: dataSource, + ); + break; } if (videoPlayerOptions?.mixWithOthers != null) { diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 7f6f608687cc..86eee3c9bf42 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.1.15 +version: 2.2.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -24,7 +24,7 @@ dependencies: flutter: sdk: flutter meta: ^1.3.0 - video_player_platform_interface: ^4.1.0 + video_player_platform_interface: ^4.2.0 # The design on https://flutter.dev/go/federated-plugins was to leave # this constraint as "any". We cannot do it right now as it fails pub publish # validation, so we set a ^ constraint. The exact value doesn't matter since diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index ad536f840c1d..5fdc1fbf4574 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -284,6 +284,15 @@ void main() { }); }); + test('contentUri', () async { + final VideoPlayerController controller = + VideoPlayerController.contentUri(Uri.parse('content://video')); + await controller.initialize(); + + expect(fakeVideoPlayerPlatform.dataSourceDescriptions[0].uri, + 'content://video'); + }); + test('dispose', () async { final VideoPlayerController controller = VideoPlayerController.network( 'https://127.0.0.1', diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index a7a198db21e1..4eb7c9d610b5 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.4 + +* Adopt `video_player_platform_interface` 4.2 and opt out of `contentUri` data source. + ## 2.0.3 * Add `implements` to pubspec. diff --git a/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart b/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart index d3ad80c890f1..2a830c9c573d 100644 --- a/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart +++ b/packages/video_player/video_player_web/example/integration_test/video_player_web_test.dart @@ -68,6 +68,17 @@ void main() { throwsUnimplementedError); }); + testWidgets('cannot create from content URI', (WidgetTester tester) async { + expect( + VideoPlayerPlatform.instance.create( + DataSource( + sourceType: DataSourceType.contentUri, + uri: 'content://video', + ), + ), + throwsUnimplementedError); + }); + testWidgets('can dispose', (WidgetTester tester) async { expect(VideoPlayerPlatform.instance.dispose(await textureId), completes); }); diff --git a/packages/video_player/video_player_web/lib/video_player_web.dart b/packages/video_player/video_player_web/lib/video_player_web.dart index f9e27d16725a..612d22d2eb3f 100644 --- a/packages/video_player/video_player_web/lib/video_player_web.dart +++ b/packages/video_player/video_player_web/lib/video_player_web.dart @@ -88,6 +88,9 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { case DataSourceType.file: return Future.error(UnimplementedError( 'web implementation of video_player cannot play local files')); + case DataSourceType.contentUri: + return Future.error(UnimplementedError( + 'web implementation of video_player cannot play content uri')); } final _VideoPlayer player = _VideoPlayer( diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index c5eb57c1fc6e..b401673c628d 100644 --- a/packages/video_player/video_player_web/pubspec.yaml +++ b/packages/video_player/video_player_web/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_web description: Web platform implementation of video_player. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.0.3 +version: 2.0.4 environment: sdk: ">=2.12.0 <3.0.0" @@ -22,7 +22,7 @@ dependencies: flutter_web_plugins: sdk: flutter meta: ^1.3.0 - video_player_platform_interface: ^4.0.0 + video_player_platform_interface: ^4.2.0 dev_dependencies: flutter_test: From 5e4b5e0330ab3b7525331ef853a4c06fc8e068e9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 10 Sep 2021 09:52:03 +0200 Subject: [PATCH 271/364] [webview_flutter] Add download listener to Android webview (#4322) --- .../webview_flutter/CHANGELOG.md | 3 +- .../FlutterDownloadListener.java | 33 ++++++++++ .../webviewflutter/FlutterWebView.java | 29 ++++++--- .../webviewflutter/FlutterWebViewClient.java | 16 +++++ .../webviewflutter/WebViewBuilder.java | 16 ++++- .../FlutterDownloadListenerTest.java | 42 +++++++++++++ .../FlutterWebViewClientTest.java | 60 +++++++++++++++++++ .../webviewflutter/FlutterWebViewTest.java | 7 ++- .../webviewflutter/WebViewBuilderTest.java | 7 ++- .../webview_flutter/pubspec.yaml | 2 +- 10 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java create mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java create mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 361bfd24f3af..1e1d5aa523ba 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.0.13 +* Send URL of File to download to the NavigationDelegate on Android just like it is already done on iOS. * Updated Android lint settings. ## 2.0.12 diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java new file mode 100644 index 000000000000..cfad4e315514 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java @@ -0,0 +1,33 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.webkit.DownloadListener; +import android.webkit.WebView; + +/** DownloadListener to notify the {@link FlutterWebViewClient} of download starts */ +public class FlutterDownloadListener implements DownloadListener { + private final FlutterWebViewClient webViewClient; + private WebView webView; + + public FlutterDownloadListener(FlutterWebViewClient webViewClient) { + this.webViewClient = webViewClient; + } + + /** Sets the {@link WebView} that the result of the navigation delegate will be send to. */ + public void setWebView(WebView webView) { + this.webView = webView; + } + + @Override + public void onDownloadStart( + String url, + String userAgent, + String contentDisposition, + String mimetype, + long contentLength) { + webViewClient.notifyDownload(webView, url); + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index a3b681f27980..4651a5f5ae22 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -11,12 +11,14 @@ import android.os.Handler; import android.os.Message; import android.view.View; +import android.webkit.DownloadListener; import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; import android.webkit.WebStorage; import android.webkit.WebView; import android.webkit.WebViewClient; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -94,18 +96,25 @@ public void onProgressChanged(WebView view, int progress) { (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); displayListenerProxy.onPreWebViewInitialization(displayManager); + this.methodChannel = methodChannel; + this.methodChannel.setMethodCallHandler(this); + + flutterWebViewClient = new FlutterWebViewClient(methodChannel); + + FlutterDownloadListener flutterDownloadListener = + new FlutterDownloadListener(flutterWebViewClient); webView = createWebView( - new WebViewBuilder(context, containerView), params, new FlutterWebChromeClient()); + new WebViewBuilder(context, containerView), + params, + new FlutterWebChromeClient(), + flutterDownloadListener); + flutterDownloadListener.setWebView(webView); displayListenerProxy.onPostWebViewInitialization(displayManager); platformThreadHandler = new Handler(context.getMainLooper()); - this.methodChannel = methodChannel; - this.methodChannel.setMethodCallHandler(this); - - flutterWebViewClient = new FlutterWebViewClient(methodChannel); Map settings = (Map) params.get("settings"); if (settings != null) { applySettings(settings); @@ -156,7 +165,10 @@ public void onProgressChanged(WebView view, int progress) { */ @VisibleForTesting static WebView createWebView( - WebViewBuilder webViewBuilder, Map params, WebChromeClient webChromeClient) { + WebViewBuilder webViewBuilder, + Map params, + WebChromeClient webChromeClient, + @Nullable DownloadListener downloadListener) { boolean usesHybridComposition = Boolean.TRUE.equals(params.get("usesHybridComposition")); webViewBuilder .setUsesHybridComposition(usesHybridComposition) @@ -164,8 +176,9 @@ static WebView createWebView( .setJavaScriptCanOpenWindowsAutomatically( true) // Always allow automatically opening of windows. .setSupportMultipleWindows(true) // Always support multiple windows. - .setWebChromeClient( - webChromeClient); // Always use {@link FlutterWebChromeClient} as web Chrome client. + .setWebChromeClient(webChromeClient) + .setDownloadListener( + downloadListener); // Always use {@link FlutterWebChromeClient} as web Chrome client. return webViewBuilder.build(); } diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java index adc84671a701..260ef8e8b15d 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -115,6 +115,22 @@ boolean shouldOverrideUrlLoading(WebView view, String url) { return true; } + /** + * Notifies the Flutter code that a download should start when a navigation delegate is set. + * + * @param view the webView the result of the navigation delegate will be send to. + * @param url the download url + * @return A boolean whether or not the request is forwarded to the Flutter code. + */ + boolean notifyDownload(WebView view, String url) { + if (!hasNavigationDelegate) { + return false; + } + + notifyOnNavigationRequest(url, null, view, true); + return true; + } + private void onPageStarted(WebView view, String url) { Map args = new HashMap<>(); args.put("url", url); diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java index 6b8cc51febe8..d3cd1d57cdae 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java @@ -6,6 +6,7 @@ import android.content.Context; import android.view.View; +import android.webkit.DownloadListener; import android.webkit.WebChromeClient; import android.webkit.WebSettings; import android.webkit.WebView; @@ -44,6 +45,7 @@ static WebView create(Context context, boolean usesHybridComposition, View conta private boolean supportMultipleWindows; private boolean usesHybridComposition; private WebChromeClient webChromeClient; + private DownloadListener downloadListener; /** * Constructs a new {@link WebViewBuilder} object with a custom implementation of the {@link @@ -122,6 +124,18 @@ public WebViewBuilder setWebChromeClient(@Nullable WebChromeClient webChromeClie return this; } + /** + * Registers the interface to be used when content can not be handled by the rendering engine, and + * should be downloaded instead. This will replace the current handler. + * + * @param downloadListener an implementation of DownloadListener This value may be null. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setDownloadListener(@Nullable DownloadListener downloadListener) { + this.downloadListener = downloadListener; + return this; + } + /** * Build the {@link android.webkit.WebView} using the current settings. * @@ -135,7 +149,7 @@ public WebView build() { webSettings.setJavaScriptCanOpenWindowsAutomatically(javaScriptCanOpenWindowsAutomatically); webSettings.setSupportMultipleWindows(supportMultipleWindows); webView.setWebChromeClient(webChromeClient); - + webView.setDownloadListener(downloadListener); return webView; } } diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java new file mode 100644 index 000000000000..2c918584ba83 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java @@ -0,0 +1,42 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.webkit.WebView; +import org.junit.Before; +import org.junit.Test; + +public class FlutterDownloadListenerTest { + private FlutterWebViewClient webViewClient; + private WebView webView; + + @Before + public void before() { + webViewClient = mock(FlutterWebViewClient.class); + webView = mock(WebView.class); + } + + @Test + public void onDownloadStart_should_notify_webViewClient() { + String url = "testurl.com"; + FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); + downloadListener.onDownloadStart(url, "test", "inline", "data/text", 0); + verify(webViewClient).notifyDownload(nullable(WebView.class), eq(url)); + } + + @Test + public void onDownloadStart_should_pass_webView() { + FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); + downloadListener.setWebView(webView); + downloadListener.onDownloadStart("testurl.com", "test", "inline", "data/text", 0); + verify(webViewClient).notifyDownload(eq(webView), anyString()); + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java new file mode 100644 index 000000000000..86346ac08f16 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java @@ -0,0 +1,60 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import android.webkit.WebView; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +public class FlutterWebViewClientTest { + + MethodChannel mockMethodChannel; + WebView mockWebView; + + @Before + public void before() { + mockMethodChannel = mock(MethodChannel.class); + mockWebView = mock(WebView.class); + } + + @Test + public void notify_download_should_notifyOnNavigationRequest_when_navigationDelegate_is_set() { + final String url = "testurl.com"; + + FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); + client.createWebViewClient(true); + + client.notifyDownload(mockWebView, url); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockMethodChannel) + .invokeMethod( + eq("navigationRequest"), argumentCaptor.capture(), any(MethodChannel.Result.class)); + HashMap map = (HashMap) argumentCaptor.getValue(); + assertEquals(map.get("url"), url); + assertEquals(map.get("isForMainFrame"), true); + } + + @Test + public void + notify_download_should_not_notifyOnNavigationRequest_when_navigationDelegate_is_not_set() { + final String url = "testurl.com"; + + FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); + client.createWebViewClient(false); + + client.notifyDownload(mockWebView, url); + verifyNoInteractions(mockMethodChannel); + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java index 96cbdece387c..56d9db1ee493 100644 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java +++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.webkit.DownloadListener; import android.webkit.WebChromeClient; import android.webkit.WebView; import java.util.HashMap; @@ -20,6 +21,7 @@ public class FlutterWebViewTest { private WebChromeClient mockWebChromeClient; + private DownloadListener mockDownloadListener; private WebViewBuilder mockWebViewBuilder; private WebView mockWebView; @@ -28,6 +30,7 @@ public void before() { mockWebChromeClient = mock(WebChromeClient.class); mockWebViewBuilder = mock(WebViewBuilder.class); mockWebView = mock(WebView.class); + mockDownloadListener = mock(DownloadListener.class); when(mockWebViewBuilder.setDomStorageEnabled(anyBoolean())).thenReturn(mockWebViewBuilder); when(mockWebViewBuilder.setJavaScriptCanOpenWindowsAutomatically(anyBoolean())) @@ -36,6 +39,8 @@ public void before() { when(mockWebViewBuilder.setUsesHybridComposition(anyBoolean())).thenReturn(mockWebViewBuilder); when(mockWebViewBuilder.setWebChromeClient(any(WebChromeClient.class))) .thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setDownloadListener(any(DownloadListener.class))) + .thenReturn(mockWebViewBuilder); when(mockWebViewBuilder.build()).thenReturn(mockWebView); } @@ -43,7 +48,7 @@ public void before() { @Test public void createWebView_should_create_webview_with_default_configuration() { FlutterWebView.createWebView( - mockWebViewBuilder, createParameterMap(false), mockWebChromeClient); + mockWebViewBuilder, createParameterMap(false), mockWebChromeClient, mockDownloadListener); verify(mockWebViewBuilder, times(1)).setDomStorageEnabled(true); verify(mockWebViewBuilder, times(1)).setJavaScriptCanOpenWindowsAutomatically(true); diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java index 48fbce231ed5..423cb210c392 100644 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java +++ b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java @@ -9,6 +9,7 @@ import android.content.Context; import android.view.View; +import android.webkit.DownloadListener; import android.webkit.WebChromeClient; import android.webkit.WebSettings; import android.webkit.WebView; @@ -60,6 +61,7 @@ public void ctor_test() { public void build_should_set_values() throws IOException { WebSettings mockWebSettings = mock(WebSettings.class); WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + DownloadListener mockDownloadListener = mock(DownloadListener.class); when(mockWebView.getSettings()).thenReturn(mockWebSettings); @@ -68,7 +70,8 @@ public void build_should_set_values() throws IOException { .setDomStorageEnabled(true) .setJavaScriptCanOpenWindowsAutomatically(true) .setSupportMultipleWindows(true) - .setWebChromeClient(mockWebChromeClient); + .setWebChromeClient(mockWebChromeClient) + .setDownloadListener(mockDownloadListener); WebView webView = builder.build(); @@ -77,6 +80,7 @@ public void build_should_set_values() throws IOException { verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(true); verify(mockWebSettings).setSupportMultipleWindows(true); verify(mockWebView).setWebChromeClient(mockWebChromeClient); + verify(mockWebView).setDownloadListener(mockDownloadListener); } @Test @@ -95,5 +99,6 @@ public void build_should_use_default_values() throws IOException { verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false); verify(mockWebSettings).setSupportMultipleWindows(false); verify(mockWebView).setWebChromeClient(null); + verify(mockWebView).setDownloadListener(null); } } diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index cc5d9cdc8b96..3976ff74fef6 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.12 +version: 2.0.13 environment: sdk: ">=2.12.0 <3.0.0" From b38c4e491afda039afc766e366bd0aeb813ab3c7 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 10 Sep 2021 16:07:06 -0400 Subject: [PATCH 272/364] [flutter_plugin_tools] Remove an unnecessary logging message (#4320) --- script/tool/lib/src/publish_plugin_command.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart index 769b9e8c8f00..4fdecf603eec 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -433,7 +433,6 @@ final String _credentialsPath = () { // https://github.com/dart-lang/pub/blob/d99b0d58f4059d7bb4ac4616fd3d54ec00a2b5d4/lib/src/system_cache.dart#L34-L43 String? cacheDir; final String? pubCache = io.Platform.environment['PUB_CACHE']; - print(pubCache); if (pubCache != null) { cacheDir = pubCache; } else if (io.Platform.isWindows) { From a4f0e88fca1e8ea1e76913695ffb365646d9a039 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 10 Sep 2021 16:07:16 -0400 Subject: [PATCH 273/364] [flutter_plugin_tools] Make having no Java unit tests a failure (#4310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This brings the native Android unit tests in line with the policy of having tests that we expect all plugins to have—unless there's a very specific reason to opt them out—fail when missing instead of skipping when missing, to help guard against errors where we silently fail to run tests we think we are running. Adds an explicit exclusion list covering the plugins that have a reason to be opted out. Android portion of flutter/flutter#85469 --- .cirrus.yml | 3 +- .../configs/exclude_native_unit_android.yaml | 11 +++ script/tool/CHANGELOG.md | 5 + script/tool/lib/src/native_test_command.dart | 15 ++- .../tool/test/native_test_command_test.dart | 97 +++++++++++++++++-- 5 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 script/configs/exclude_native_unit_android.yaml diff --git a/.cirrus.yml b/.cirrus.yml index 9ca3a875c01e..a118942580e6 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -182,7 +182,8 @@ task: - export CIRRUS_COMMIT_MESSAGE="" # Native integration tests are handled by firebase-test-lab below, so # only run unit tests. - - ./script/tool_runner.sh native-test --android --no-integration # must come after apk build + # Must come after build-examples. + - ./script/tool_runner.sh native-test --android --no-integration --exclude script/configs/exclude_native_unit_android.yaml firebase_test_lab_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. diff --git a/script/configs/exclude_native_unit_android.yaml b/script/configs/exclude_native_unit_android.yaml new file mode 100644 index 000000000000..5ec80eee73a0 --- /dev/null +++ b/script/configs/exclude_native_unit_android.yaml @@ -0,0 +1,11 @@ +# Deprecated; no plan to backfill the missing files +- android_alarm_manager +- battery +- device_info/device_info +- package_info +- sensors +- share +- wifi_info_flutter/wifi_info_flutter + +# No need for unit tests: +- espresso diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index aa73c65f3e80..c585bee47206 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +- `native-test --android` now fails plugins that don't have unit tests, + rather than skipping them. + ## 0.7.1 - Add support for `.pluginToolsConfig.yaml` in the `build-examples` command. diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index e50878db7906..78a82afc571c 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -242,7 +242,8 @@ this command. final Iterable examples = plugin.getExamples(); - bool ranTests = false; + bool ranUnitTests = false; + bool ranAnyTests = false; bool failed = false; bool hasMissingBuild = false; for (final RepositoryPackage example in examples) { @@ -289,7 +290,8 @@ this command. printError('$exampleName unit tests failed.'); failed = true; } - ranTests = true; + ranUnitTests = true; + ranAnyTests = true; } if (runIntegrationTests) { @@ -311,7 +313,7 @@ this command. printError('$exampleName integration tests failed.'); failed = true; } - ranTests = true; + ranAnyTests = true; } } @@ -321,7 +323,12 @@ this command. ? 'Examples must be built before testing.' : null); } - if (!ranTests) { + if (!mode.integrationOnly && !ranUnitTests) { + printError('No unit tests ran. Plugins are required to have unit tests.'); + return _PlatformResult(RunState.failed, + error: 'No unit tests ran (use --exclude if this is intentional).'); + } + if (!ranAnyTests) { return _PlatformResult(RunState.skipped); } return _PlatformResult(RunState.succeeded); diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index d1ab11f6e50d..f7b2ea5c0de8 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -430,7 +430,8 @@ void main() { ], ); - await runCapturingPrint(runner, ['native-test', '--android']); + await runCapturingPrint( + runner, ['native-test', '--android', '--no-unit']); final Directory androidFolder = plugin.childDirectory('example').childDirectory('android'); @@ -467,7 +468,8 @@ void main() { ], ); - await runCapturingPrint(runner, ['native-test', '--android']); + await runCapturingPrint( + runner, ['native-test', '--android', '--no-unit']); // Nothing should run since those files are all // integration_test-specific. @@ -641,7 +643,11 @@ void main() { ); final List output = await runCapturingPrint( - runner, ['native-test', '--android']); + runner, ['native-test', '--android'], + errorHandler: (Error e) { + // Having no unit tests is fatal, but that's not the point of this + // test so just ignore the failure. + }); expect( output, @@ -654,7 +660,7 @@ void main() { ])); }); - test('fails when a test fails', () async { + test('fails when a unit test fails', () async { final Directory pluginDir = createFakePlugin( 'plugin', packagesDir, @@ -695,6 +701,84 @@ void main() { ); }); + test('fails when an integration test fails', () async { + final Directory pluginDir = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess(), // unit passes + MockProcess(exitCode: 1), // integration fails + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('plugin/example integration tests failed.'), + contains('The following packages had errors:'), + contains('plugin') + ]), + ); + }); + + test('fails if there are no unit tests', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('No Android unit tests found for plugin/example'), + contains( + 'No unit tests ran. Plugins are required to have unit tests.'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' No unit tests ran (use --exclude if this is intentional).') + ]), + ); + }); + test('skips if Android is not supported', () async { createFakePlugin( 'plugin', @@ -713,7 +797,7 @@ void main() { ); }); - test('skips when running no tests', () async { + test('skips when running no tests in integration-only mode', () async { createFakePlugin( 'plugin', packagesDir, @@ -723,12 +807,11 @@ void main() { ); final List output = await runCapturingPrint( - runner, ['native-test', '--android']); + runner, ['native-test', '--android', '--no-unit']); expect( output, containsAllInOrder([ - contains('No Android unit tests found for plugin/example'), contains('No Android integration tests found for plugin/example'), contains('SKIPPING: No tests found.'), ]), From 70c314ce53cfd7c85d0ab498c8aaf334a69262d4 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Fri, 10 Sep 2021 17:36:22 -0700 Subject: [PATCH 274/364] [google_maps_flutter_web] Fix getScreenCoordinate, zIndex of Circles (#4298) This commit: * uses the zIndex attribute when converting Circle geometry objects. * ensures that the getScreenCoordinate method works as expected on the web platform. * adds tests that can use a fully-rendered Google Map (see projection_test.dart) * changes the initialization flow of the web Google Map, so the Controller is only returned to the main plugin when it's ready to work. In order to test the getScreenCoordinate method, the Controller of a fully-rendered map must be available on the test, so we can retrieve information from an actual map instance. While working on this, it was observed that the Controller was being sent to the programmer before it was truly ready (while the map was still initializing). Instead of littering the test with imprecise timeouts that may make these tests slower (and flakier) than needed, this PR also changes the initialization process of a GMap slightly so when its Controller is returned to the user of the plugin (onPlatformViewCreated method call), it is truly ready. For this: * Controller.init is immediately called after the controller is created, * The plugin waits for the first onTilesloaded event coming from the JS SDK, and then * The Controller is sent to the user This change happens within "private" sections of the plugin, so programmers using the plugin "normally" shouldn't notice any difference whatsoever (only that the GMap might load slightly faster, and the onPlatformViewCreated callback might be firing a few hundred milliseconds later). --- .../google_maps_flutter_web/CHANGELOG.md | 7 + .../google_maps_flutter_web/LICENSE | 26 ++ .../google_maps_controller_test.dart | 19 +- .../google_maps_controller_test.mocks.dart | 77 ++--- .../google_maps_plugin_test.dart | 60 ++-- .../google_maps_plugin_test.mocks.dart | 120 +++++--- .../integration_test/projection_test.dart | 265 ++++++++++++++++++ .../example/pubspec.yaml | 6 +- .../lib/google_maps_flutter_web.dart | 1 + .../lib/src/convert.dart | 7 +- .../lib/src/google_maps_controller.dart | 46 ++- .../lib/src/google_maps_flutter_web.dart | 14 +- .../third_party/to_screen_location/LICENSE | 21 ++ .../third_party/to_screen_location/README.md | 14 + .../to_screen_location.dart | 57 ++++ .../google_maps_flutter_web/pubspec.yaml | 4 +- .../tool/lib/src/license_check_command.dart | 24 +- 17 files changed, 636 insertions(+), 132 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 83ffe09b357d..4d7ecf74e098 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.3.1 + +* Fix the `getScreenCoordinate(LatLng)` method. [#80710](https://github.com/flutter/flutter/issues/80710) +* Wait until the map tiles have loaded before calling `onPlatformViewCreated`, so +the returned controller is 100% functional (has bounds, a projection, etc...) +* Use zIndex property when initializing Circle objects. [#89374](https://github.com/flutter/flutter/issues/89374) + ## 0.3.0+4 * Add `implements` to pubspec. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/LICENSE b/packages/google_maps_flutter/google_maps_flutter_web/LICENSE index c6823b81eb84..8f8c01d50118 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/LICENSE +++ b/packages/google_maps_flutter/google_maps_flutter_web/LICENSE @@ -1,3 +1,5 @@ +google_maps_flutter_web + Copyright 2013 The Flutter Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, @@ -23,3 +25,27 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- +to_screen_location + +The MIT License (MIT) + +Copyright (c) 2008 Krasimir Tsonev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart index 1d33eea4c7f3..39aa641b10e4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart @@ -257,13 +257,19 @@ void main() { }); testWidgets('renders initial geometry', (WidgetTester tester) async { - controller = _createController(circles: { - Circle(circleId: CircleId('circle-1')) - }, markers: { + controller = _createController(circles: { + Circle( + circleId: CircleId('circle-1'), + zIndex: 1234, + ), + }, markers: { Marker( - markerId: MarkerId('marker-1'), - infoWindow: InfoWindow( - title: 'title for test', snippet: 'snippet for test')) + markerId: MarkerId('marker-1'), + infoWindow: InfoWindow( + title: 'title for test', + snippet: 'snippet for test', + ), + ), }, polygons: { Polygon(polygonId: PolygonId('polygon-1'), points: [ LatLng(43.355114, -5.851333), @@ -315,6 +321,7 @@ void main() { .captured[0] as Set; expect(capturedCircles.first.circleId.value, 'circle-1'); + expect(capturedCircles.first.zIndex, 1234); expect(capturedMarkers.first.markerId.value, 'marker-1'); expect(capturedMarkers.first.infoWindow.snippet, 'snippet for test'); expect(capturedMarkers.first.infoWindow.title, 'title for test'); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart index 47933285b208..af8ed5420a0c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart @@ -2,26 +2,25 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Mocks generated by Mockito 5.0.2 from annotations +// Mocks generated by Mockito 5.0.15 from annotations // in google_maps_flutter_web_integration_tests/integration_test/google_maps_controller_test.dart. // Do not manually edit this file. -import 'package:google_maps/src/generated/google_maps_core.js.g.dart' as _i2; -import 'package:google_maps_flutter_platform_interface/src/types/circle.dart' +import 'package:google_maps/google_maps.dart' as _i2; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart' as _i4; -import 'package:google_maps_flutter_platform_interface/src/types/marker.dart' - as _i7; -import 'package:google_maps_flutter_platform_interface/src/types/polygon.dart' - as _i5; -import 'package:google_maps_flutter_platform_interface/src/types/polyline.dart' - as _i6; import 'package:google_maps_flutter_web/google_maps_flutter_web.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis -class _FakeGMap extends _i1.Fake implements _i2.GMap {} +class _FakeGMap_0 extends _i1.Fake implements _i2.GMap {} /// A class which mocks [CirclesController]. /// @@ -34,7 +33,7 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { as Map<_i4.CircleId, _i3.CircleController>); @override _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); + returnValue: _FakeGMap_0()) as _i2.GMap); @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), @@ -62,6 +61,8 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { void bindToMap(int? mapId, _i2.GMap? googleMap) => super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), returnValueForMissingStub: null); + @override + String toString() => super.toString(); } /// A class which mocks [PolygonsController]. @@ -70,13 +71,13 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { class MockPolygonsController extends _i1.Mock implements _i3.PolygonsController { @override - Map<_i5.PolygonId, _i3.PolygonController> get polygons => + Map<_i4.PolygonId, _i3.PolygonController> get polygons => (super.noSuchMethod(Invocation.getter(#polygons), - returnValue: <_i5.PolygonId, _i3.PolygonController>{}) - as Map<_i5.PolygonId, _i3.PolygonController>); + returnValue: <_i4.PolygonId, _i3.PolygonController>{}) + as Map<_i4.PolygonId, _i3.PolygonController>); @override _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); + returnValue: _FakeGMap_0()) as _i2.GMap); @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), @@ -89,21 +90,23 @@ class MockPolygonsController extends _i1.Mock super.noSuchMethod(Invocation.setter(#mapId, _mapId), returnValueForMissingStub: null); @override - void addPolygons(Set<_i5.Polygon>? polygonsToAdd) => + void addPolygons(Set<_i4.Polygon>? polygonsToAdd) => super.noSuchMethod(Invocation.method(#addPolygons, [polygonsToAdd]), returnValueForMissingStub: null); @override - void changePolygons(Set<_i5.Polygon>? polygonsToChange) => + void changePolygons(Set<_i4.Polygon>? polygonsToChange) => super.noSuchMethod(Invocation.method(#changePolygons, [polygonsToChange]), returnValueForMissingStub: null); @override - void removePolygons(Set<_i5.PolygonId>? polygonIdsToRemove) => super + void removePolygons(Set<_i4.PolygonId>? polygonIdsToRemove) => super .noSuchMethod(Invocation.method(#removePolygons, [polygonIdsToRemove]), returnValueForMissingStub: null); @override void bindToMap(int? mapId, _i2.GMap? googleMap) => super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), returnValueForMissingStub: null); + @override + String toString() => super.toString(); } /// A class which mocks [PolylinesController]. @@ -112,13 +115,13 @@ class MockPolygonsController extends _i1.Mock class MockPolylinesController extends _i1.Mock implements _i3.PolylinesController { @override - Map<_i6.PolylineId, _i3.PolylineController> get lines => + Map<_i4.PolylineId, _i3.PolylineController> get lines => (super.noSuchMethod(Invocation.getter(#lines), - returnValue: <_i6.PolylineId, _i3.PolylineController>{}) - as Map<_i6.PolylineId, _i3.PolylineController>); + returnValue: <_i4.PolylineId, _i3.PolylineController>{}) + as Map<_i4.PolylineId, _i3.PolylineController>); @override _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); + returnValue: _FakeGMap_0()) as _i2.GMap); @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), @@ -131,21 +134,23 @@ class MockPolylinesController extends _i1.Mock super.noSuchMethod(Invocation.setter(#mapId, _mapId), returnValueForMissingStub: null); @override - void addPolylines(Set<_i6.Polyline>? polylinesToAdd) => + void addPolylines(Set<_i4.Polyline>? polylinesToAdd) => super.noSuchMethod(Invocation.method(#addPolylines, [polylinesToAdd]), returnValueForMissingStub: null); @override - void changePolylines(Set<_i6.Polyline>? polylinesToChange) => super + void changePolylines(Set<_i4.Polyline>? polylinesToChange) => super .noSuchMethod(Invocation.method(#changePolylines, [polylinesToChange]), returnValueForMissingStub: null); @override - void removePolylines(Set<_i6.PolylineId>? polylineIdsToRemove) => super + void removePolylines(Set<_i4.PolylineId>? polylineIdsToRemove) => super .noSuchMethod(Invocation.method(#removePolylines, [polylineIdsToRemove]), returnValueForMissingStub: null); @override void bindToMap(int? mapId, _i2.GMap? googleMap) => super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), returnValueForMissingStub: null); + @override + String toString() => super.toString(); } /// A class which mocks [MarkersController]. @@ -153,13 +158,13 @@ class MockPolylinesController extends _i1.Mock /// See the documentation for Mockito's code generation for more information. class MockMarkersController extends _i1.Mock implements _i3.MarkersController { @override - Map<_i7.MarkerId, _i3.MarkerController> get markers => + Map<_i4.MarkerId, _i3.MarkerController> get markers => (super.noSuchMethod(Invocation.getter(#markers), - returnValue: <_i7.MarkerId, _i3.MarkerController>{}) - as Map<_i7.MarkerId, _i3.MarkerController>); + returnValue: <_i4.MarkerId, _i3.MarkerController>{}) + as Map<_i4.MarkerId, _i3.MarkerController>); @override _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); + returnValue: _FakeGMap_0()) as _i2.GMap); @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), @@ -172,31 +177,33 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { super.noSuchMethod(Invocation.setter(#mapId, _mapId), returnValueForMissingStub: null); @override - void addMarkers(Set<_i7.Marker>? markersToAdd) => + void addMarkers(Set<_i4.Marker>? markersToAdd) => super.noSuchMethod(Invocation.method(#addMarkers, [markersToAdd]), returnValueForMissingStub: null); @override - void changeMarkers(Set<_i7.Marker>? markersToChange) => + void changeMarkers(Set<_i4.Marker>? markersToChange) => super.noSuchMethod(Invocation.method(#changeMarkers, [markersToChange]), returnValueForMissingStub: null); @override - void removeMarkers(Set<_i7.MarkerId>? markerIdsToRemove) => + void removeMarkers(Set<_i4.MarkerId>? markerIdsToRemove) => super.noSuchMethod(Invocation.method(#removeMarkers, [markerIdsToRemove]), returnValueForMissingStub: null); @override - void showMarkerInfoWindow(_i7.MarkerId? markerId) => + void showMarkerInfoWindow(_i4.MarkerId? markerId) => super.noSuchMethod(Invocation.method(#showMarkerInfoWindow, [markerId]), returnValueForMissingStub: null); @override - void hideMarkerInfoWindow(_i7.MarkerId? markerId) => + void hideMarkerInfoWindow(_i4.MarkerId? markerId) => super.noSuchMethod(Invocation.method(#hideMarkerInfoWindow, [markerId]), returnValueForMissingStub: null); @override - bool isInfoWindowShown(_i7.MarkerId? markerId) => + bool isInfoWindowShown(_i4.MarkerId? markerId) => (super.noSuchMethod(Invocation.method(#isInfoWindowShown, [markerId]), returnValue: false) as bool); @override void bindToMap(int? mapId, _i2.GMap? googleMap) => super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), returnValueForMissingStub: null); + @override + String toString() => super.toString(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart index 2de431a5445e..758294f5bb91 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart @@ -28,16 +28,18 @@ void main() { group('GoogleMapsPlugin', () { late MockGoogleMapController controller; late GoogleMapsPlugin plugin; - int? reportedMapId; + late Completer reportedMapIdCompleter; + int numberOnPlatformViewCreatedCalls = 0; void onPlatformViewCreated(int id) { - reportedMapId = id; + reportedMapIdCompleter.complete(id); + numberOnPlatformViewCreatedCalls++; } setUp(() { controller = MockGoogleMapController(); plugin = GoogleMapsPlugin(); - reportedMapId = null; + reportedMapIdCompleter = Completer(); }); group('init/dispose', () { @@ -52,12 +54,6 @@ void main() { plugin.debugSetMapById({0: controller}); }); - testWidgets('init initializes controller', (WidgetTester tester) async { - await plugin.init(0); - - verify(controller.init()); - }); - testWidgets('cannot call methods after dispose', (WidgetTester tester) async { plugin.dispose(mapId: 0); @@ -95,17 +91,17 @@ void main() { reason: 'view type should contain the mapId passed when creating the map.', ); - expect( - reportedMapId, - testMapId, - reason: 'Should call onPlatformViewCreated with the mapId', - ); expect(cache, contains(testMapId)); expect( cache[testMapId], isNotNull, reason: 'cached controller cannot be null.', ); + expect( + cache[testMapId]!.isInitialized, + isTrue, + reason: 'buildView calls init on the controller', + ); }); testWidgets('returns cached instance if it already exists', @@ -121,11 +117,41 @@ void main() { ); expect(widget, equals(expected)); + }); + + testWidgets( + 'asynchronously reports onPlatformViewCreated the first time it happens', + (WidgetTester tester) async { + final Map cache = {}; + plugin.debugSetMapById(cache); + + plugin.buildView( + testMapId, + onPlatformViewCreated, + initialCameraPosition: initialCameraPosition, + ); + + // Simulate Google Maps JS SDK being "ready" + cache[testMapId]!.stream.add(WebMapReadyEvent(testMapId)); + + expect( + cache[testMapId]!.isInitialized, + isTrue, + reason: 'buildView calls init on the controller', + ); + expect( + await reportedMapIdCompleter.future, + testMapId, + reason: 'Should call onPlatformViewCreated with the mapId', + ); + + // Fire repeated event again... + cache[testMapId]!.stream.add(WebMapReadyEvent(testMapId)); expect( - reportedMapId, - isNull, + numberOnPlatformViewCreatedCalls, + equals(1), reason: - 'onPlatformViewCreated should not be called when returning a cached controller', + 'Should not call onPlatformViewCreated for the same controller multiple times', ); }); }); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart index 43150f63ef93..01908ce777e7 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart @@ -2,41 +2,34 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Mocks generated by Mockito 5.0.2 from annotations +// Mocks generated by Mockito 5.0.15 from annotations // in google_maps_flutter_web_integration_tests/integration_test/google_maps_plugin_test.dart. // Do not manually edit this file. -import 'dart:async' as _i5; +import 'dart:async' as _i2; -import 'package:google_maps_flutter_platform_interface/src/events/map_event.dart' - as _i6; -import 'package:google_maps_flutter_platform_interface/src/types/camera.dart' - as _i7; -import 'package:google_maps_flutter_platform_interface/src/types/circle_updates.dart' - as _i8; -import 'package:google_maps_flutter_platform_interface/src/types/location.dart' - as _i2; -import 'package:google_maps_flutter_platform_interface/src/types/marker.dart' - as _i12; -import 'package:google_maps_flutter_platform_interface/src/types/marker_updates.dart' - as _i11; -import 'package:google_maps_flutter_platform_interface/src/types/polygon_updates.dart' - as _i9; -import 'package:google_maps_flutter_platform_interface/src/types/polyline_updates.dart' - as _i10; -import 'package:google_maps_flutter_platform_interface/src/types/screen_coordinate.dart' +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart' as _i3; import 'package:google_maps_flutter_web/google_maps_flutter_web.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis -class _FakeLatLngBounds extends _i1.Fake implements _i2.LatLngBounds {} +class _FakeStreamController_0 extends _i1.Fake + implements _i2.StreamController {} -class _FakeScreenCoordinate extends _i1.Fake implements _i3.ScreenCoordinate {} +class _FakeLatLngBounds_1 extends _i1.Fake implements _i3.LatLngBounds {} -class _FakeLatLng extends _i1.Fake implements _i2.LatLng {} +class _FakeScreenCoordinate_2 extends _i1.Fake implements _i3.ScreenCoordinate { +} + +class _FakeLatLng_3 extends _i1.Fake implements _i3.LatLng {} /// A class which mocks [GoogleMapController]. /// @@ -44,63 +37,98 @@ class _FakeLatLng extends _i1.Fake implements _i2.LatLng {} class MockGoogleMapController extends _i1.Mock implements _i4.GoogleMapController { @override - _i5.Stream<_i6.MapEvent> get events => + _i2.StreamController<_i3.MapEvent> get stream => + (super.noSuchMethod(Invocation.getter(#stream), + returnValue: _FakeStreamController_0<_i3.MapEvent>()) + as _i2.StreamController<_i3.MapEvent>); + @override + _i2.Stream<_i3.MapEvent> get events => (super.noSuchMethod(Invocation.getter(#events), - returnValue: Stream<_i6.MapEvent>.empty()) - as _i5.Stream<_i6.MapEvent>); + returnValue: Stream<_i3.MapEvent>.empty()) + as _i2.Stream<_i3.MapEvent>); + @override + bool get isInitialized => + (super.noSuchMethod(Invocation.getter(#isInitialized), returnValue: false) + as bool); + @override + void debugSetOverrides( + {_i4.DebugCreateMapFunction? createMap, + _i4.MarkersController? markers, + _i4.CirclesController? circles, + _i4.PolygonsController? polygons, + _i4.PolylinesController? polylines}) => + super.noSuchMethod( + Invocation.method(#debugSetOverrides, [], { + #createMap: createMap, + #markers: markers, + #circles: circles, + #polygons: polygons, + #polylines: polylines + }), + returnValueForMissingStub: null); + @override + void init() => super.noSuchMethod(Invocation.method(#init, []), + returnValueForMissingStub: null); @override void updateRawOptions(Map? optionsUpdate) => super.noSuchMethod(Invocation.method(#updateRawOptions, [optionsUpdate]), returnValueForMissingStub: null); @override - _i5.Future<_i2.LatLngBounds> getVisibleRegion() => - (super.noSuchMethod(Invocation.method(#getVisibleRegion, []), - returnValue: Future.value(_FakeLatLngBounds())) - as _i5.Future<_i2.LatLngBounds>); + _i2.Future<_i3.LatLngBounds> getVisibleRegion() => (super.noSuchMethod( + Invocation.method(#getVisibleRegion, []), + returnValue: Future<_i3.LatLngBounds>.value(_FakeLatLngBounds_1())) + as _i2.Future<_i3.LatLngBounds>); @override - _i5.Future<_i3.ScreenCoordinate> getScreenCoordinate(_i2.LatLng? latLng) => + _i2.Future<_i3.ScreenCoordinate> getScreenCoordinate(_i3.LatLng? latLng) => (super.noSuchMethod(Invocation.method(#getScreenCoordinate, [latLng]), - returnValue: Future.value(_FakeScreenCoordinate())) - as _i5.Future<_i3.ScreenCoordinate>); + returnValue: + Future<_i3.ScreenCoordinate>.value(_FakeScreenCoordinate_2())) + as _i2.Future<_i3.ScreenCoordinate>); @override - _i5.Future<_i2.LatLng> getLatLng(_i3.ScreenCoordinate? screenCoordinate) => + _i2.Future<_i3.LatLng> getLatLng(_i3.ScreenCoordinate? screenCoordinate) => (super.noSuchMethod(Invocation.method(#getLatLng, [screenCoordinate]), - returnValue: Future.value(_FakeLatLng())) as _i5.Future<_i2.LatLng>); + returnValue: Future<_i3.LatLng>.value(_FakeLatLng_3())) + as _i2.Future<_i3.LatLng>); @override - _i5.Future moveCamera(_i7.CameraUpdate? cameraUpdate) => + _i2.Future moveCamera(_i3.CameraUpdate? cameraUpdate) => (super.noSuchMethod(Invocation.method(#moveCamera, [cameraUpdate]), - returnValue: Future.value(null), - returnValueForMissingStub: Future.value()) as _i5.Future); + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i2.Future); @override - _i5.Future getZoomLevel() => + _i2.Future getZoomLevel() => (super.noSuchMethod(Invocation.method(#getZoomLevel, []), - returnValue: Future.value(0.0)) as _i5.Future); + returnValue: Future.value(0.0)) as _i2.Future); @override - void updateCircles(_i8.CircleUpdates? updates) => + void updateCircles(_i3.CircleUpdates? updates) => super.noSuchMethod(Invocation.method(#updateCircles, [updates]), returnValueForMissingStub: null); @override - void updatePolygons(_i9.PolygonUpdates? updates) => + void updatePolygons(_i3.PolygonUpdates? updates) => super.noSuchMethod(Invocation.method(#updatePolygons, [updates]), returnValueForMissingStub: null); @override - void updatePolylines(_i10.PolylineUpdates? updates) => + void updatePolylines(_i3.PolylineUpdates? updates) => super.noSuchMethod(Invocation.method(#updatePolylines, [updates]), returnValueForMissingStub: null); @override - void updateMarkers(_i11.MarkerUpdates? updates) => + void updateMarkers(_i3.MarkerUpdates? updates) => super.noSuchMethod(Invocation.method(#updateMarkers, [updates]), returnValueForMissingStub: null); @override - void showInfoWindow(_i12.MarkerId? markerId) => + void showInfoWindow(_i3.MarkerId? markerId) => super.noSuchMethod(Invocation.method(#showInfoWindow, [markerId]), returnValueForMissingStub: null); @override - void hideInfoWindow(_i12.MarkerId? markerId) => + void hideInfoWindow(_i3.MarkerId? markerId) => super.noSuchMethod(Invocation.method(#hideInfoWindow, [markerId]), returnValueForMissingStub: null); @override - bool isInfoWindowShown(_i12.MarkerId? markerId) => + bool isInfoWindowShown(_i3.MarkerId? markerId) => (super.noSuchMethod(Invocation.method(#isInfoWindowShown, [markerId]), returnValue: false) as bool); + @override + void dispose() => super.noSuchMethod(Invocation.method(#dispose, []), + returnValueForMissingStub: null); + @override + String toString() => super.toString(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart new file mode 100644 index 000000000000..8a5a62013538 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart @@ -0,0 +1,265 @@ +// Copyright 2013 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. + +// These tests render an app with a small map widget, and use its map controller +// to compute values of the default projection. + +// (Tests methods that can't be mocked in `google_maps_controller_test.dart`) + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart' + show GoogleMap, GoogleMapController; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +// This value is used when comparing long~num, like LatLng values. +const _acceptableLatLngDelta = 0.0000000001; + +// This value is used when comparing pixel measurements, mostly to gloss over +// browser rounding errors. +const _acceptablePixelDelta = 1; + +/// Test Google Map Controller +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Methods that require a proper Projection', () { + final LatLng center = LatLng(43.3078, -5.6958); + final Size size = Size(320, 240); + final CameraPosition initialCamera = CameraPosition( + target: center, + zoom: 14, + ); + + late Completer controllerCompleter; + late void Function(GoogleMapController) onMapCreated; + + setUp(() { + controllerCompleter = Completer(); + onMapCreated = (GoogleMapController mapController) { + controllerCompleter.complete(mapController); + }; + }); + + group('getScreenCoordinate', () { + testWidgets('target of map is in center of widget', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + + final GoogleMapController controller = await controllerCompleter.future; + + final ScreenCoordinate screenPosition = + await controller.getScreenCoordinate(center); + + expect( + screenPosition.x, + closeTo(size.width / 2, _acceptablePixelDelta), + ); + expect( + screenPosition.y, + closeTo(size.height / 2, _acceptablePixelDelta), + ); + }); + + testWidgets('NorthWest of visible region corresponds to x:0, y:0', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng northWest = LatLng( + bounds.northeast.latitude, + bounds.southwest.longitude, + ); + + final ScreenCoordinate screenPosition = + await controller.getScreenCoordinate(northWest); + + expect(screenPosition.x, closeTo(0, _acceptablePixelDelta)); + expect(screenPosition.y, closeTo(0, _acceptablePixelDelta)); + }); + + testWidgets( + 'SouthEast of visible region corresponds to x:size.width, y:size.height', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng southEast = LatLng( + bounds.southwest.latitude, + bounds.northeast.longitude, + ); + + final ScreenCoordinate screenPosition = + await controller.getScreenCoordinate(southEast); + + expect(screenPosition.x, closeTo(size.width, _acceptablePixelDelta)); + expect(screenPosition.y, closeTo(size.height, _acceptablePixelDelta)); + }); + }); + + group('getLatLng', () { + testWidgets('Center of widget is the target of map', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + + final GoogleMapController controller = await controllerCompleter.future; + + final LatLng coords = await controller.getLatLng( + ScreenCoordinate(x: size.width ~/ 2, y: size.height ~/ 2), + ); + + expect( + coords.latitude, + closeTo(center.latitude, _acceptableLatLngDelta), + ); + expect( + coords.longitude, + closeTo(center.longitude, _acceptableLatLngDelta), + ); + }); + + testWidgets('Top-left of widget is NorthWest bound of map', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng northWest = LatLng( + bounds.northeast.latitude, + bounds.southwest.longitude, + ); + + final LatLng coords = await controller.getLatLng( + ScreenCoordinate(x: 0, y: 0), + ); + + expect( + coords.latitude, + closeTo(northWest.latitude, _acceptableLatLngDelta), + ); + expect( + coords.longitude, + closeTo(northWest.longitude, _acceptableLatLngDelta), + ); + }); + + testWidgets('Bottom-right of widget is SouthWest bound of map', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng southEast = LatLng( + bounds.southwest.latitude, + bounds.northeast.longitude, + ); + + final LatLng coords = await controller.getLatLng( + ScreenCoordinate(x: size.width.toInt(), y: size.height.toInt()), + ); + + expect( + coords.latitude, + closeTo(southEast.latitude, _acceptableLatLngDelta), + ); + expect( + coords.longitude, + closeTo(southEast.longitude, _acceptableLatLngDelta), + ); + }); + }); + }); +} + +// Pumps a CenteredMap Widget into a given tester, with some parameters +void pumpCenteredMap( + WidgetTester tester, { + required CameraPosition initialCamera, + Size size = const Size(320, 240), + void Function(GoogleMapController)? onMapCreated, +}) async { + await tester.pumpWidget( + CenteredMap( + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ), + ); + + // This is needed to kick-off the rendering of the JS Map flutter widget + await tester.pump(); +} + +/// Renders a Map widget centered on the screen. +/// This depends in `package:google_maps_flutter` to work. +class CenteredMap extends StatelessWidget { + const CenteredMap({ + required this.initialCamera, + required this.size, + required this.onMapCreated, + Key? key, + }) : super(key: key); + + /// A function that receives the [GoogleMapController] of the Map widget once initialized. + final void Function(GoogleMapController)? onMapCreated; + + /// The size of the rendered map widget. + final Size size; + + /// The initial camera position (center + zoom level) of the Map widget. + final CameraPosition initialCamera; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.fromSize( + size: size, + child: GoogleMap( + initialCameraPosition: initialCamera, + onMapCreated: onMapCreated, + ), + ), + ), + ), + ); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml index b0ac9910afc9..249b893d198c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml @@ -13,8 +13,10 @@ dependencies: sdk: flutter dev_dependencies: - build_runner: ^1.11.0 - google_maps: ^5.1.0 + build_runner: ^2.1.1 + google_maps: ^5.2.0 + google_maps_flutter: # Used for projection_test.dart + path: ../../google_maps_flutter http: ^0.13.0 mockito: ^5.0.0 flutter_driver: diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart index 6dc2dab572a6..0355f2923528 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart @@ -25,6 +25,7 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_maps/google_maps.dart' as gmaps; +import 'src/third_party/to_screen_location/to_screen_location.dart'; import 'src/types.dart'; part 'src/google_maps_flutter_web.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart index 2e71c795ff0e..c026a03be804 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart @@ -264,7 +264,7 @@ gmaps.MarkerOptions _markerOptionsFromMarker( } gmaps.CircleOptions _circleOptionsFromCircle(Circle circle) { - final populationOptions = gmaps.CircleOptions() + final circleOptions = gmaps.CircleOptions() ..strokeColor = _getCssColor(circle.strokeColor) ..strokeOpacity = _getCssOpacity(circle.strokeColor) ..strokeWeight = circle.strokeWidth @@ -272,8 +272,9 @@ gmaps.CircleOptions _circleOptionsFromCircle(Circle circle) { ..fillOpacity = _getCssOpacity(circle.fillColor) ..center = gmaps.LatLng(circle.center.latitude, circle.center.longitude) ..radius = circle.radius - ..visible = circle.visible; - return populationOptions; + ..visible = circle.visible + ..zIndex = circle.zIndex; + return circleOptions; } gmaps.PolygonOptions _polygonOptionsFromPolygon( diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart index 226268270579..edf47764f346 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart @@ -53,6 +53,10 @@ class GoogleMapController { // The StreamController used by this controller and the geometry ones. final StreamController _streamController; + /// The StreamController for the events of this Map. Only for integration testing. + @visibleForTesting + StreamController get stream => _streamController; + /// The Stream over which this controller broadcasts events. Stream get events => _streamController.stream; @@ -132,10 +136,27 @@ class GoogleMapController { return gmaps.GMap(div, options); } - /// Initializes the [gmaps.GMap] instance from the stored `rawOptions`. + /// A flag that returns true if the controller has been initialized or not. + @visibleForTesting + bool get isInitialized => _googleMap != null; + + /// Starts the JS Maps SDK into the target [_div] with `rawOptions`. + /// + /// (Also initializes the geometry/traffic layers.) + /// + /// The first part of this method starts the rendering of a [gmaps.GMap] inside + /// of the target [_div], with configuration from `rawOptions`. It then stores + /// the created GMap in the [_googleMap] attribute. /// - /// This method actually renders the GMap into the cached `_div`. This is - /// called by the [GoogleMapsPlugin.init] method when appropriate. + /// Not *everything* is rendered with the initial `rawOptions` configuration, + /// geometry and traffic layers (and possibly others in the future) have their + /// own configuration and are rendered on top of a GMap instance later. This + /// happens in the second half of this method. + /// + /// This method is eagerly called from the [GoogleMapsPlugin.buildView] method + /// so the internal [GoogleMapsController] of a Web Map initializes as soon as + /// possible. Check [_attachMapEvents] to see how this controller notifies the + /// plugin of it being fully ready (through the `onTilesloaded.first` event). /// /// Failure to call this method would result in the GMap not rendering at all, /// and most of the public methods on this class no-op'ing. @@ -151,6 +172,7 @@ class GoogleMapController { _attachMapEvents(map); _attachGeometryControllers(map); + // Now attach the geometry, traffic and any other layers... _renderInitialGeometry( markers: _markers, circles: _circles, @@ -163,6 +185,10 @@ class GoogleMapController { // Funnels map gmap events into the plugin's stream controller. void _attachMapEvents(gmaps.GMap map) { + map.onTilesloaded.first.then((event) { + // Report the map as ready to go the first time the tiles load + _streamController.add(WebMapReadyEvent(_mapId)); + }); map.onClick.listen((event) { assert(event.latLng != null); _streamController.add( @@ -292,14 +318,8 @@ class GoogleMapController { Future getScreenCoordinate(LatLng latLng) async { assert(_googleMap != null, 'Cannot get the screen coordinates with a null map.'); - assert(_googleMap!.projection != null, - 'Cannot compute screen coordinate with a null map or projection.'); - - final point = - _googleMap!.projection!.fromLatLngToPoint!(_latLngToGmLatLng(latLng))!; - assert(point.x != null && point.y != null, - 'The x and y of a ScreenCoordinate cannot be null.'); + final point = toScreenLocation(_googleMap!, _latLngToGmLatLng(latLng)); return ScreenCoordinate(x: point.x!.toInt(), y: point.y!.toInt()); } @@ -403,3 +423,9 @@ class GoogleMapController { _streamController.close(); } } + +/// An event fired when a [mapId] on web is interactive. +class WebMapReadyEvent extends MapEvent { + /// Build a WebMapReady Event for the map represented by `mapId`. + WebMapReadyEvent(int mapId) : super(mapId, null); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart index 692917fef4da..d03dec93ce3f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart @@ -35,7 +35,10 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { @override Future init(int mapId) async { - _map(mapId).init(); + // The internal instance of our controller is initialized eagerly in `buildView`, + // so we don't have to do anything in this method, which is left intentionally + // blank. + assert(_map(mapId) != null, 'Must call buildWidget before init!'); } /// Updates the options of a given `mapId`. @@ -305,11 +308,16 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { polylines: polylines, circles: circles, mapOptions: mapOptions, - ); + )..init(); // Initialize the controller _mapById[creationId] = mapController; - onPlatformViewCreated.call(creationId); + mapController.events.whereType().first.then((event) { + assert(creationId == event.mapId, + 'Received WebMapReadyEvent for the wrong map'); + // Notify the plugin now that there's a fully initialized controller. + onPlatformViewCreated.call(event.mapId); + }); assert(mapController.widget != null, 'The widget of a GoogleMapController cannot be null before calling dispose on it.'); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE new file mode 100644 index 000000000000..ab4e163abe54 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2008 Krasimir Tsonev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md new file mode 100644 index 000000000000..8bd4a39c065f --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md @@ -0,0 +1,14 @@ +# to_screen_location + +The code in this directory is a Dart re-implementation of Krasimir Tsonev's blog +post: [GoogleMaps API v3: convert LatLng object to actual pixels][blog-post]. + +The blog post describes a way to implement the [`toScreenLocation` method][method] +of the Google Maps Platform SDK for the web. + +Used under license (MIT), [available here][blog-license], and in the accompanying +LICENSE file. + +[blog-license]: https://krasimirtsonev.com/license +[blog-post]: https://krasimirtsonev.com/blog/article/google-maps-api-v3-convert-latlng-object-to-actual-pixels-point-object +[method]: https://developers.google.com/maps/documentation/android-sdk/reference/com/google/android/libraries/maps/Projection#toScreenLocation(com.google.android.libraries.maps.model.LatLng) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart new file mode 100644 index 000000000000..2963111fdcc3 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart @@ -0,0 +1,57 @@ +// The MIT License (MIT) +// +// Copyright (c) 2008 Krasimir Tsonev +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'package:google_maps/google_maps.dart' as gmaps; + +/// Returns a screen location that corresponds to a geographical coordinate ([gmaps.LatLng]). +/// +/// The screen location is in pixels relative to the top left of the Map widget +/// (not of the whole screen/app). +/// +/// See: https://developers.google.com/maps/documentation/android-sdk/reference/com/google/android/libraries/maps/Projection#public-point-toscreenlocation-latlng-location +gmaps.Point toScreenLocation(gmaps.GMap map, gmaps.LatLng coords) { + final zoom = map.zoom; + final bounds = map.bounds; + final projection = map.projection; + + assert( + bounds != null, 'Map Bounds required to compute screen x/y of LatLng.'); + assert(projection != null, + 'Map Projection required to compute screen x/y of LatLng.'); + assert(zoom != null, + 'Current map zoom level required to compute screen x/y of LatLng.'); + + final ne = bounds!.northEast; + final sw = bounds.southWest; + + final topRight = projection!.fromLatLngToPoint!(ne)!; + final bottomLeft = projection.fromLatLngToPoint!(sw)!; + + final scale = 1 << (zoom!.toInt()); // 2 ^ zoom + + final worldPoint = projection.fromLatLngToPoint!(coords)!; + + return gmaps.Point( + ((worldPoint.x! - bottomLeft.x!) * scale).toInt(), + ((worldPoint.y! - topRight.y!) * scale).toInt(), + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index 82605f8fd070..8a23916b0e98 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.3.0+4 +version: 0.3.1 environment: sdk: ">=2.12.0 <3.0.0" @@ -22,7 +22,7 @@ dependencies: flutter_web_plugins: sdk: flutter google_maps_flutter_platform_interface: ^2.0.1 - google_maps: ^5.1.0 + google_maps: ^5.2.0 meta: ^1.3.0 sanitize_html: ^2.0.0 stream_transform: ^2.0.0 diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index e68585c44bdf..8cee46b45a4c 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -49,16 +49,24 @@ const Set _ignoredFullBasenameList = { // When adding license regexes here, include the copyright info to ensure that // any new additions are flagged for added scrutiny in review. final List _thirdPartyLicenseBlockRegexes = [ -// Third-party code used in url_launcher_web. + // Third-party code used in url_launcher_web. RegExp( - r'^// Copyright 2017 Workiva Inc\..*' - r'^// Licensed under the Apache License, Version 2\.0', - multiLine: true, - dotAll: true), + r'^// Copyright 2017 Workiva Inc\..*' + r'^// Licensed under the Apache License, Version 2\.0', + multiLine: true, + dotAll: true, + ), + // Third-party code used in google_maps_flutter_web. + RegExp( + r'^// The MIT License [^C]+ Copyright \(c\) 2008 Krasimir Tsonev', + multiLine: true, + ), // bsdiff in flutter/packages. - RegExp(r'// Copyright 2003-2005 Colin Percival\. All rights reserved\.\n' - r'// Use of this source code is governed by a BSD-style license that can be\n' - r'// found in the LICENSE file\.\n'), + RegExp( + r'// Copyright 2003-2005 Colin Percival\. All rights reserved\.\n' + r'// Use of this source code is governed by a BSD-style license that can be\n' + r'// found in the LICENSE file\.\n', + ), ]; // The exact format of the BSD license that our license files should contain. From cfc8a20a1d64ec33ad549344819dae63af76f88b Mon Sep 17 00:00:00 2001 From: keyonghan <54558023+keyonghan@users.noreply.github.com> Date: Mon, 13 Sep 2021 11:47:06 -0700 Subject: [PATCH 275/364] renew cirrus key (#4340) --- .cirrus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cirrus.yml b/.cirrus.yml index a118942580e6..d92c12cef61c 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -1,4 +1,4 @@ -gcp_credentials: ENCRYPTED[!0e63b52bd7e4fda1cd7b7bf2b4fe515a27fadbeaced01f5ad8b699b81d3611ed64c5d3271bcd8426dd914ef41cba48a0!] +gcp_credentials: ENCRYPTED[!48cff44dd32e9cc412d4d381c7fe68d373ca04cf2639f8192d21cb1a9ab5e21129651423a1cf88f3fd7fe2125c1cabd9!] # Don't run on release tags since it creates O(n^2) tasks where n is the # number of plugins From 4a98e239b131971e0a83d4383c709bc3205a333f Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 13 Sep 2021 19:37:12 -0400 Subject: [PATCH 276/364] [flutter_plugin_tools] Make no unit tests fatal for iOS/macOS (#4341) Brings iOS and macOS into alignment with the other platforms, where having unit tests set up is required. - For deprecated plugins with no tests, `--exclude`s them, as on other platforms. - For `quick_actions` and `share`, which have integration tests but no unit tests, sets up the unit test scaffolding. (This is done for `share` even though it's deprecated since unlike other platforms, iOS/macOS runs both native tests in the same command, and setting up a special way to exclude just units tests for that one case would be much more effort.) Fixes flutter/flutter#85469 --- .cirrus.yml | 2 +- .../quick_actions/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 105 +++++ .../xcshareddata/xcschemes/Runner.xcscheme | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/ios/RunnerTests/Info.plist | 22 + .../example/ios/RunnerTests/RunnerTests.m | 18 + packages/share/example/ios/Podfile | 3 + .../ios/Runner.xcodeproj/project.pbxproj | 105 +++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../share/example/ios/RunnerTests/Info.plist | 22 + .../example/ios/RunnerTests/RunnerTests.m | 18 + script/configs/exclude_native_ios.yaml | 7 + script/tool/CHANGELOG.md | 4 +- script/tool/lib/src/native_test_command.dart | 50 ++- .../tool/test/native_test_command_test.dart | 397 ++++++++---------- 16 files changed, 543 insertions(+), 239 deletions(-) create mode 100644 packages/quick_actions/quick_actions/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/quick_actions/quick_actions/example/ios/RunnerTests/Info.plist create mode 100644 packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m create mode 100644 packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/share/example/ios/RunnerTests/Info.plist create mode 100644 packages/share/example/ios/RunnerTests/RunnerTests.m create mode 100644 script/configs/exclude_native_ios.yaml diff --git a/.cirrus.yml b/.cirrus.yml index d92c12cef61c..5b696031b304 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -256,7 +256,7 @@ task: xcode_analyze_script: - ./script/tool_runner.sh xcode-analyze --ios native_test_script: - - ./script/tool_runner.sh native-test --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" + - ./script/tool_runner.sh native-test --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" --exclude=script/configs/exclude_native_ios.yaml drive_script: # `drive-examples` contains integration tests, which changes the UI of the application. # This UI change sometimes affects `xctest`. diff --git a/packages/quick_actions/quick_actions/example/ios/Podfile b/packages/quick_actions/quick_actions/example/ios/Podfile index f7d6a5e68c3a..3924e59aa0f9 100644 --- a/packages/quick_actions/quick_actions/example/ios/Podfile +++ b/packages/quick_actions/quick_actions/example/ios/Podfile @@ -29,6 +29,9 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj index ee150598f59b..745315bcc3d2 100644 --- a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 33E20B3526EFCDFC00A4A191 /* RunnerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33E20B3426EFCDFC00A4A191 /* RunnerTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 686BE83025E58CCF00862533 /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686BE82F25E58CCF00862533 /* RunnerUITests.m */; }; 83C36CAF23D629E5ABE75B2A /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CCC799F2B0AB50A9C34344F0 /* libPods-Runner.a */; }; @@ -19,6 +20,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 33E20B3726EFCDFC00A4A191 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; 686BE83225E58CCF00862533 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -44,6 +52,9 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 33E20B3226EFCDFC00A4A191 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 33E20B3426EFCDFC00A4A191 /* RunnerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RunnerTests.m; sourceTree = ""; }; + 33E20B3626EFCDFC00A4A191 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 5278439583922091276A37C9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 686BE82D25E58CCF00862533 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -65,6 +76,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 33E20B2F26EFCDFC00A4A191 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 686BE82A25E58CCF00862533 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -83,6 +101,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 33E20B3326EFCDFC00A4A191 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 33E20B3426EFCDFC00A4A191 /* RunnerTests.m */, + 33E20B3626EFCDFC00A4A191 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; 686BE82E25E58CCF00862533 /* RunnerUITests */ = { isa = PBXGroup; children = ( @@ -109,6 +136,7 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 686BE82E25E58CCF00862533 /* RunnerUITests */, + 33E20B3326EFCDFC00A4A191 /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, D0FE95BE2380323DD75CB891 /* Pods */, A44AD0D63DEF785A2A2DEE28 /* Frameworks */, @@ -120,6 +148,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 686BE82D25E58CCF00862533 /* RunnerUITests.xctest */, + 33E20B3226EFCDFC00A4A191 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -168,6 +197,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 33E20B3126EFCDFC00A4A191 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33E20B3B26EFCDFC00A4A191 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 33E20B2E26EFCDFC00A4A191 /* Sources */, + 33E20B2F26EFCDFC00A4A191 /* Frameworks */, + 33E20B3026EFCDFC00A4A191 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 33E20B3826EFCDFC00A4A191 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 33E20B3226EFCDFC00A4A191 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 686BE82C25E58CCF00862533 /* RunnerUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 686BE83625E58CCF00862533 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; @@ -216,6 +263,10 @@ LastUpgradeCheck = 1100; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { + 33E20B3126EFCDFC00A4A191 = { + CreatedOnToolsVersion = 12.5; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 686BE82C25E58CCF00862533 = { CreatedOnToolsVersion = 12.4; ProvisioningStyle = Automatic; @@ -241,11 +292,19 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 686BE82C25E58CCF00862533 /* RunnerUITests */, + 33E20B3126EFCDFC00A4A191 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 33E20B3026EFCDFC00A4A191 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 686BE82B25E58CCF00862533 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -316,6 +375,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 33E20B2E26EFCDFC00A4A191 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33E20B3526EFCDFC00A4A191 /* RunnerTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 686BE82925E58CCF00862533 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -337,6 +404,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 33E20B3826EFCDFC00A4A191 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 33E20B3726EFCDFC00A4A191 /* PBXContainerItemProxy */; + }; 686BE83325E58CCF00862533 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -364,6 +436,30 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 33E20B3926EFCDFC00A4A191 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 33E20B3A26EFCDFC00A4A191 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; 686BE83425E58CCF00862533 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -560,6 +656,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 33E20B3B26EFCDFC00A4A191 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33E20B3926EFCDFC00A4A191 /* Debug */, + 33E20B3A26EFCDFC00A4A191 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 686BE83625E58CCF00862533 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9850cc113026..ac798eda8c17 100644 --- a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -47,6 +47,16 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/quick_actions/quick_actions/example/ios/RunnerTests/Info.plist b/packages/quick_actions/quick_actions/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m b/packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m new file mode 100644 index 000000000000..64e0f7e1d8b2 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 quick_actions; +@import XCTest; + +@interface QuickActionsTests : XCTestCase +@end + +@implementation QuickActionsTests + +- (void)testPlugin { + FLTQuickActionsPlugin* plugin = [[FLTQuickActionsPlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end diff --git a/packages/share/example/ios/Podfile b/packages/share/example/ios/Podfile index f7d6a5e68c3a..3924e59aa0f9 100644 --- a/packages/share/example/ios/Podfile +++ b/packages/share/example/ios/Podfile @@ -29,6 +29,9 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/share/example/ios/Runner.xcodeproj/project.pbxproj b/packages/share/example/ios/Runner.xcodeproj/project.pbxproj index 16acd6a7b886..d7e896212533 100644 --- a/packages/share/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/share/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 28918A213BCB94C5470742D8 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 85392794417D70A970945C83 /* libPods-Runner.a */; }; 2D9222511EC45DE6007564B0 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 2D9222501EC45DE6007564B0 /* GeneratedPluginRegistrant.m */; }; + 33E20B4326EFCEF400A4A191 /* RunnerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33E20B4226EFCEF400A4A191 /* RunnerTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 683426AE2538D314009B194C /* FLTShareExampleUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 683426AD2538D314009B194C /* FLTShareExampleUITests.m */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; @@ -19,6 +20,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 33E20B4526EFCEF400A4A191 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; 683426B02538D314009B194C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -46,6 +54,9 @@ 1BCE6CBBA2E91FD0397A29C8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 2D92224F1EC45DE6007564B0 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 2D9222501EC45DE6007564B0 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 33E20B4026EFCEF400A4A191 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 33E20B4226EFCEF400A4A191 /* RunnerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RunnerTests.m; sourceTree = ""; }; + 33E20B4426EFCEF400A4A191 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 683426AB2538D314009B194C /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 683426AD2538D314009B194C /* FLTShareExampleUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTShareExampleUITests.m; sourceTree = ""; }; @@ -65,6 +76,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 33E20B3D26EFCEF400A4A191 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 683426A82538D314009B194C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -92,6 +110,15 @@ name = Pods; sourceTree = ""; }; + 33E20B4126EFCEF400A4A191 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 33E20B4226EFCEF400A4A191 /* RunnerTests.m */, + 33E20B4426EFCEF400A4A191 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; 683426AC2538D314009B194C /* RunnerUITests */ = { isa = PBXGroup; children = ( @@ -126,6 +153,7 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 683426AC2538D314009B194C /* RunnerUITests */, + 33E20B4126EFCEF400A4A191 /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, 16DDF472245BCC3E62219493 /* Pods */, 8CA31EF57239BF20619316D9 /* Frameworks */, @@ -137,6 +165,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 683426AB2538D314009B194C /* RunnerUITests.xctest */, + 33E20B4026EFCEF400A4A191 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -168,6 +197,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 33E20B3F26EFCEF400A4A191 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33E20B4926EFCEF400A4A191 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 33E20B3C26EFCEF400A4A191 /* Sources */, + 33E20B3D26EFCEF400A4A191 /* Frameworks */, + 33E20B3E26EFCEF400A4A191 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 33E20B4626EFCEF400A4A191 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 33E20B4026EFCEF400A4A191 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 683426AA2538D314009B194C /* RunnerUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 683426B42538D314009B194C /* Build configuration list for PBXNativeTarget "RunnerUITests" */; @@ -216,6 +263,10 @@ LastUpgradeCheck = 1100; ORGANIZATIONNAME = "The Flutter Authors"; TargetAttributes = { + 33E20B3F26EFCEF400A4A191 = { + CreatedOnToolsVersion = 12.5; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 683426AA2538D314009B194C = { CreatedOnToolsVersion = 11.7; ProvisioningStyle = Automatic; @@ -241,11 +292,19 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 683426AA2538D314009B194C /* RunnerUITests */, + 33E20B3F26EFCEF400A4A191 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 33E20B3E26EFCEF400A4A191 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 683426A92538D314009B194C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -316,6 +375,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 33E20B3C26EFCEF400A4A191 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33E20B4326EFCEF400A4A191 /* RunnerTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 683426A72538D314009B194C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -337,6 +404,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 33E20B4626EFCEF400A4A191 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 33E20B4526EFCEF400A4A191 /* PBXContainerItemProxy */; + }; 683426B12538D314009B194C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -364,6 +436,30 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 33E20B4726EFCEF400A4A191 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 33E20B4826EFCEF400A4A191 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; 683426B22538D314009B194C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -560,6 +656,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 33E20B4926EFCEF400A4A191 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33E20B4726EFCEF400A4A191 /* Debug */, + 33E20B4826EFCEF400A4A191 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 683426B42538D314009B194C /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/share/example/ios/RunnerTests/Info.plist b/packages/share/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/share/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/share/example/ios/RunnerTests/RunnerTests.m b/packages/share/example/ios/RunnerTests/RunnerTests.m new file mode 100644 index 000000000000..3c4c341fd451 --- /dev/null +++ b/packages/share/example/ios/RunnerTests/RunnerTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 share; +@import XCTest; + +@interface ShareTests : XCTestCase +@end + +@implementation ShareTests + +- (void)testPlugin { + FLTSharePlugin* plugin = [[FLTSharePlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end diff --git a/script/configs/exclude_native_ios.yaml b/script/configs/exclude_native_ios.yaml new file mode 100644 index 000000000000..723fcfa64715 --- /dev/null +++ b/script/configs/exclude_native_ios.yaml @@ -0,0 +1,7 @@ +# Deprecated; no plan to backfill the missing files +- battery +- connectivity/connectivity +- device_info/device_info +- package_info +- sensors +- wifi_info_flutter/wifi_info_flutter diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index c585bee47206..7df6913db7d1 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,7 +1,7 @@ ## NEXT -- `native-test --android` now fails plugins that don't have unit tests, - rather than skipping them. +- `native-test --android`, `--ios`, and `--macos` now fail plugins that don't + have unit tests, rather than skipping them. ## 0.7.1 diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 78a82afc571c..4911b4aeb156 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -251,8 +251,6 @@ this command. final bool hasIntegrationTests = exampleHasNativeIntegrationTests(example); - // TODO(stuartmorgan): Make !hasUnitTests fatal. See - // https://github.com/flutter/flutter/issues/85469 if (mode.unit && !hasUnitTests) { _printNoExampleTestsMessage(example, 'Android unit'); } @@ -355,33 +353,40 @@ this command. List extraFlags = const [], }) async { String? testTarget; + const String unitTestTarget = 'RunnerTests'; if (mode.unitOnly) { - testTarget = 'RunnerTests'; + testTarget = unitTestTarget; } else if (mode.integrationOnly) { testTarget = 'RunnerUITests'; } + bool ranUnitTests = false; // Assume skipped until at least one test has run. RunState overallResult = RunState.skipped; for (final RepositoryPackage example in plugin.getExamples()) { final String exampleName = example.displayName; - // TODO(stuartmorgan): Always check for RunnerTests, and make it fatal if - // no examples have it. See - // https://github.com/flutter/flutter/issues/85469 - if (testTarget != null) { - final Directory project = example.directory - .childDirectory(platform.toLowerCase()) - .childDirectory('Runner.xcodeproj'); + // If running a specific target, check that. Otherwise, check if there + // are unit tests, since having no unit tests for a plugin is fatal + // (by repo policy) even if there are integration tests. + bool exampleHasUnitTests = false; + final String? targetToCheck = + testTarget ?? (mode.unit ? unitTestTarget : null); + final Directory xcodeProject = example.directory + .childDirectory(platform.toLowerCase()) + .childDirectory('Runner.xcodeproj'); + if (targetToCheck != null) { final bool? hasTarget = - await _xcode.projectHasTarget(project, testTarget); + await _xcode.projectHasTarget(xcodeProject, targetToCheck); if (hasTarget == null) { printError('Unable to check targets for $exampleName.'); overallResult = RunState.failed; continue; } else if (!hasTarget) { - print('No "$testTarget" target in $exampleName; skipping.'); + print('No "$targetToCheck" target in $exampleName; skipping.'); continue; + } else if (targetToCheck == unitTestTarget) { + exampleHasUnitTests = true; } } @@ -404,20 +409,39 @@ this command. switch (exitCode) { case _xcodebuildNoTestExitCode: _printNoExampleTestsMessage(example, platform); - continue; + break; case 0: printSuccess('Successfully ran $platform xctest for $exampleName'); // If this is the first test, assume success until something fails. if (overallResult == RunState.skipped) { overallResult = RunState.succeeded; } + if (exampleHasUnitTests) { + ranUnitTests = true; + } break; default: // Any failure means a failure overall. overallResult = RunState.failed; + // If unit tests ran, note that even if they failed. + if (exampleHasUnitTests) { + ranUnitTests = true; + } break; } } + + if (!mode.integrationOnly && !ranUnitTests) { + printError('No unit tests ran. Plugins are required to have unit tests.'); + // Only return a specific summary error message about the missing unit + // tests if there weren't also failures, to avoid having a misleadingly + // specific message. + if (overallResult != RunState.failed) { + return _PlatformResult(RunState.failed, + error: 'No unit tests ran (use --exclude if this is intentional).'); + } + } + return _PlatformResult(overallResult); } diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index f7b2ea5c0de8..ba93efcb3ace 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -78,6 +78,61 @@ void main() { runner.addCommand(command); }); + // Returns a MockProcess to provide for "xcrun xcodebuild -list" for a + // project that contains [targets]. + MockProcess _getMockXcodebuildListProcess(List targets) { + final Map projects = { + 'project': { + 'targets': targets, + } + }; + return MockProcess(stdout: jsonEncode(projects)); + } + + // Returns the ProcessCall to expect for checking the targets present in + // the [package]'s [platform]/Runner.xcodeproj. + ProcessCall _getTargetCheckCall(Directory package, String platform) { + return ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + package + .childDirectory(platform) + .childDirectory('Runner.xcodeproj') + .path, + ], + null); + } + + // Returns the ProcessCall to expect for running the tests in the + // workspace [platform]/Runner.xcworkspace, with the given extra flags. + ProcessCall _getRunTestCall( + Directory package, + String platform, { + String? destination, + List extraFlags = const [], + }) { + return ProcessCall( + 'xcrun', + [ + 'xcodebuild', + 'test', + '-workspace', + '$platform/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + if (destination != null) ...['-destination', destination], + ...extraFlags, + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + package.path); + } + test('fails if no platforms are provided', () async { Error? commandError; final List output = await runCapturingPrint( @@ -124,31 +179,26 @@ void main() { pluginDirectory1.childDirectory('example'); processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess(['RunnerTests', 'RunnerUITests']), // Exit code 66 from testing indicates no tests. MockProcess(exitCode: 66), ]; - final List output = - await runCapturingPrint(runner, ['native-test', '--macos']); + final List output = await runCapturingPrint( + runner, ['native-test', '--macos', '--no-unit']); - expect(output, contains(contains('No tests found.'))); + expect( + output, + containsAllInOrder([ + contains('No tests found.'), + contains('Skipped 1 package(s)'), + ])); expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos', + extraFlags: ['-only-testing:RunnerUITests']), ])); }); @@ -196,6 +246,11 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + final List output = await runCapturingPrint(runner, [ 'native-test', '--ios', @@ -213,22 +268,9 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'foo_destination'), ])); }); @@ -243,6 +285,8 @@ void main() { processRunner.mockProcessesForExecutable['xcrun'] = [ MockProcess(stdout: jsonEncode(_kDeviceListMap)), // simctl + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), ]; await runCapturingPrint(runner, ['native-test', '--ios']); @@ -261,22 +305,9 @@ void main() { '--json', ], null), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A'), ])); }); }); @@ -325,6 +356,11 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + final List output = await runCapturingPrint(runner, [ 'native-test', '--macos', @@ -338,20 +374,8 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos'), ])); }); }); @@ -999,13 +1023,9 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - const Map projects = { - 'project': { - 'targets': ['RunnerTests', 'RunnerUITests'] - } - }; processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), ]; final List output = await runCapturingPrint(runner, [ @@ -1023,34 +1043,9 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-only-testing:RunnerTests', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos', + extraFlags: ['-only-testing:RunnerTests']), ])); }); @@ -1064,13 +1059,9 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); - const Map projects = { - 'project': { - 'targets': ['RunnerTests', 'RunnerUITests'] - } - }; processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), ]; final List output = await runCapturingPrint(runner, [ @@ -1088,34 +1079,9 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-only-testing:RunnerUITests', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos', + extraFlags: ['-only-testing:RunnerUITests']), ])); }); @@ -1130,13 +1096,8 @@ void main() { pluginDirectory1.childDirectory('example'); // Simulate a project with unit tests but no integration tests... - const Map projects = { - 'project': { - 'targets': ['RunnerTests'] - } - }; processRunner.mockProcessesForExecutable['xcrun'] = [ - MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + _getMockXcodebuildListProcess(['RunnerTests']), ]; // ... then try to run only integration tests. @@ -1156,19 +1117,47 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + ])); + }); + + test('fails if there are no unit tests', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess(['RunnerUITests']), + ]; + + Error? commandError; + final List output = + await runCapturingPrint(runner, ['native-test', '--macos'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No "RunnerTests" target in plugin/example; skipping.'), + contains( + 'No unit tests ran. Plugins are required to have unit tests.'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' No unit tests ran (use --exclude if this is intentional).'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), ])); }); @@ -1206,19 +1195,7 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - [ - 'xcodebuild', - '-list', - '-json', - '-project', - pluginExampleDirectory - .childDirectory('macos') - .childDirectory('Runner.xcodeproj') - .path, - ], - null), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), ])); }); }); @@ -1244,6 +1221,15 @@ void main() { final Directory androidFolder = pluginExampleDirectory.childDirectory('android'); + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), // iOS list + MockProcess(), // iOS run + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), // macOS list + MockProcess(), // macOS run + ]; + final List output = await runCapturingPrint(runner, [ 'native-test', '--android', @@ -1266,36 +1252,11 @@ void main() { orderedEquals([ ProcessCall(androidFolder.childFile('gradlew').path, const ['testDebugUnitTest'], androidFolder.path), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'foo_destination'), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos'), ])); }); @@ -1309,6 +1270,11 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory1.childDirectory('example'); + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + final List output = await runCapturingPrint(runner, [ 'native-test', '--ios', @@ -1327,20 +1293,8 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'macos/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos'), ])); }); @@ -1353,6 +1307,11 @@ void main() { final Directory pluginExampleDirectory = pluginDirectory.childDirectory('example'); + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + final List output = await runCapturingPrint(runner, [ 'native-test', '--ios', @@ -1371,22 +1330,9 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall( - 'xcrun', - const [ - 'xcodebuild', - 'test', - '-workspace', - 'ios/Runner.xcworkspace', - '-scheme', - 'Runner', - '-configuration', - 'Debug', - '-destination', - 'foo_destination', - 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', - ], - pluginExampleDirectory.path), + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'foo_destination'), ])); }); @@ -1460,6 +1406,11 @@ void main() { ], ); + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + // Simulate failing Android, but not iOS. final String gradlewPath = pluginDir .childDirectory('example') From 2076d1ddbbcfc6ba149ac214fc1848145b1124eb Mon Sep 17 00:00:00 2001 From: Yaroslav Pronin Date: Tue, 14 Sep 2021 17:27:30 +0300 Subject: [PATCH 277/364] [path_provider_linux] Using TMPDIR env as a primary temporary path (#4218) TMPDIR is a standard variable on UNIX/Linux systems, and is often used in containers such as Flatpak to redirect to a temporary folder inside a sandbox. This allows not to make hard bindings to the /tmp directory Fixes flutter/flutter#87742 --- .../path_provider_linux/CHANGELOG.md | 4 ++++ .../lib/path_provider_linux.dart | 17 +++++++++++++++- .../path_provider_linux/pubspec.yaml | 2 +- .../test/path_provider_linux_test.dart | 20 +++++++++++++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/path_provider/path_provider_linux/CHANGELOG.md b/packages/path_provider/path_provider_linux/CHANGELOG.md index 66c11a42c3eb..6f18d0d6ae58 100644 --- a/packages/path_provider/path_provider_linux/CHANGELOG.md +++ b/packages/path_provider/path_provider_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 + +* Now `getTemporaryPath` returns the value of the `TMPDIR` environment variable primarily. If `TMPDIR` is not set, `/tmp` is returned. + ## 2.0.2 * Updated installation instructions in README. diff --git a/packages/path_provider/path_provider_linux/lib/path_provider_linux.dart b/packages/path_provider/path_provider_linux/lib/path_provider_linux.dart index 38800bee1f2e..ab18db69ddfb 100644 --- a/packages/path_provider/path_provider_linux/lib/path_provider_linux.dart +++ b/packages/path_provider/path_provider_linux/lib/path_provider_linux.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:xdg_directories/xdg_directories.dart' as xdg; @@ -12,6 +13,17 @@ import 'package:xdg_directories/xdg_directories.dart' as xdg; /// /// This class implements the `package:path_provider` functionality for linux class PathProviderLinux extends PathProviderPlatform { + /// Constructs an instance of [PathProviderLinux] + PathProviderLinux() : _environment = Platform.environment; + + /// Constructs an instance of [PathProviderLinux] with the given [environment] + @visibleForTesting + PathProviderLinux.private({ + required Map environment, + }) : _environment = environment; + + final Map _environment; + /// Registers this class as the default instance of [PathProviderPlatform] static void registerWith() { PathProviderPlatform.instance = PathProviderLinux(); @@ -19,7 +31,10 @@ class PathProviderLinux extends PathProviderPlatform { @override Future getTemporaryPath() { - return Future.value('/tmp'); + final String environmentTmpDir = _environment['TMPDIR'] ?? ''; + return Future.value( + environmentTmpDir.isEmpty ? '/tmp' : environmentTmpDir, + ); } @override diff --git a/packages/path_provider/path_provider_linux/pubspec.yaml b/packages/path_provider/path_provider_linux/pubspec.yaml index 4d43302ce6b3..f5b7a88ca232 100644 --- a/packages/path_provider/path_provider_linux/pubspec.yaml +++ b/packages/path_provider/path_provider_linux/pubspec.yaml @@ -2,7 +2,7 @@ name: path_provider_linux description: Linux implementation of the path_provider plugin repository: https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider_linux issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+path_provider%22 -version: 2.0.2 +version: 2.1.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/path_provider/path_provider_linux/test/path_provider_linux_test.dart b/packages/path_provider/path_provider_linux/test/path_provider_linux_test.dart index e058d0d56039..6dd35000a8ea 100644 --- a/packages/path_provider/path_provider_linux/test/path_provider_linux_test.dart +++ b/packages/path_provider/path_provider_linux/test/path_provider_linux_test.dart @@ -13,8 +13,24 @@ void main() { expect(PathProviderPlatform.instance, isA()); }); - test('getTemporaryPath', () async { - final PathProviderPlatform plugin = PathProviderPlatform.instance; + test('getTemporaryPath defaults to TMPDIR', () async { + final PathProviderPlatform plugin = PathProviderLinux.private( + environment: {'TMPDIR': '/run/user/0/tmp'}, + ); + expect(await plugin.getTemporaryPath(), '/run/user/0/tmp'); + }); + + test('getTemporaryPath uses fallback if TMPDIR is empty', () async { + final PathProviderPlatform plugin = PathProviderLinux.private( + environment: {'TMPDIR': ''}, + ); + expect(await plugin.getTemporaryPath(), '/tmp'); + }); + + test('getTemporaryPath uses fallback if TMPDIR is unset', () async { + final PathProviderPlatform plugin = PathProviderLinux.private( + environment: {}, + ); expect(await plugin.getTemporaryPath(), '/tmp'); }); From b85edebe7134cdf2f9397a0882ba739b0319cef7 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Tue, 14 Sep 2021 13:36:53 -0700 Subject: [PATCH 278/364] [ci] use background script (#4349) Uses the background script annotation, since & no longer works. --- .cirrus.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.cirrus.yml b/.cirrus.yml index 5b696031b304..ea728267dd4c 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -212,8 +212,10 @@ task: - git clone https://github.com/flutter/web_installers.git - cd web_installers/packages/web_drivers/ - dart pub get + chromedriver_background_script: + - cd web_installers/packages/web_drivers/ - dart lib/web_driver_installer.dart chromedriver --install-only - - ./chromedriver/chromedriver --port=4444 & + - ./chromedriver/chromedriver --port=4444 build_script: - ./script/tool_runner.sh build-examples --web drive_script: From 7fff936c6c691d59db35da5750433abeabba8ffa Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Tue, 14 Sep 2021 15:09:40 -0700 Subject: [PATCH 279/364] Give the labeler access to PRs (#4348) --- .github/workflows/pull_request_label.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml index 16694f4dbf1b..825a3afd8508 100644 --- a/.github/workflows/pull_request_label.yml +++ b/.github/workflows/pull_request_label.yml @@ -14,6 +14,8 @@ on: jobs: label: + permissions: + pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/labeler@9794b1493b6f1fa7b006c5f8635a19c76c98be95 From 41cb992daef6ef7a8470714b8828316b744c4eda Mon Sep 17 00:00:00 2001 From: Maurice Parrish Date: Wed, 15 Sep 2021 00:57:03 -0400 Subject: [PATCH 280/364] Fix crash from `null` Google Maps object (#4250) --- .../google_maps_flutter/google_maps_flutter/CHANGELOG.md | 4 ++++ .../io/flutter/plugins/googlemaps/GoogleMapController.java | 6 +++++- .../flutter/plugins/googlemapsexample/GoogleMapsTest.java | 2 -- .../google_maps_flutter/google_maps_flutter/pubspec.yaml | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 3080d4a2d733..709aa3b90c19 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.9 + +* Fix Android `NullPointerException` caused by the `GoogleMapController` being disposed before `GoogleMap` was ready. + ## 2.0.8 * Mark iOS arm64 simulators as unsupported. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java index 05e016c32e27..056e10631011 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java @@ -59,7 +59,7 @@ final class GoogleMapController private final MethodChannel methodChannel; private final GoogleMapOptions options; @Nullable private MapView mapView; - private GoogleMap googleMap; + @Nullable private GoogleMap googleMap; private boolean trackCameraPosition = false; private boolean myLocationEnabled = false; private boolean myLocationButtonEnabled = false; @@ -508,6 +508,10 @@ public void dispose() { } private void setGoogleMapListener(@Nullable GoogleMapListener listener) { + if (googleMap == null) { + Log.v(TAG, "Controller was disposed before GoogleMap was ready."); + return; + } googleMap.setOnCameraMoveStartedListener(listener); googleMap.setOnCameraMoveListener(listener); googleMap.setOnCameraIdleListener(listener); diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java index 43ddeaae1579..40552ddf7be1 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java @@ -8,11 +8,9 @@ import androidx.test.core.app.ActivityScenario; import io.flutter.plugins.googlemaps.GoogleMapsPlugin; -import org.junit.Ignore; import org.junit.Test; public class GoogleMapsTest { - @Ignore("Currently failing: https://github.com/flutter/flutter/issues/87566") @Test public void googleMapsPluginIsAdded() { final ActivityScenario scenario = diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index f1dc21ae2600..9929eb935df1 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.0.8 +version: 2.0.9 environment: sdk: '>=2.12.0 <3.0.0' From 5f1c83e3933e7bc993b011721c330c241fe9796a Mon Sep 17 00:00:00 2001 From: keyonghan <54558023+keyonghan@users.noreply.github.com> Date: Tue, 14 Sep 2021 22:02:03 -0700 Subject: [PATCH 281/364] Run firebase test in flutter-cirrus (#4332) --- .cirrus.yml | 2 +- script/tool/lib/src/firebase_test_lab_command.dart | 2 +- script/tool/test/firebase_test_lab_command_test.dart | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index ea728267dd4c..0b3f07da48e1 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -159,7 +159,7 @@ task: CHANNEL: "master" CHANNEL: "stable" MAPS_API_KEY: ENCRYPTED[596a9f6bca436694625ac50851dc5da6b4d34cba8025f7db5bc9465142e8cd44e15f69e3507787753accebfc4910d550] - GCLOUD_FIREBASE_TESTLAB_KEY: ENCRYPTED[07586610af1fdfc894e5969f70ef2458341b9b7e9c3b7c4225a663b4a48732b7208a4d91c3b7d45305a6b55fa2a37fc4] + GCLOUD_FIREBASE_TESTLAB_KEY: ENCRYPTED[!c9446a7b11d5520c2ebce3c64ccc82fe6d146272cb06a4a4590e22c389f33153f951347a25422522df1a81fe2f085e9a!] build_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 4fc47c0da70c..941cba3a6945 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -27,7 +27,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { }) : super(packagesDir, processRunner: processRunner, platform: platform) { argParser.addOption( 'project', - defaultsTo: 'flutter-infra', + defaultsTo: 'flutter-cirrus', help: 'The Firebase project name.', ); final String? homeDir = io.Platform.environment['HOME']; diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index 7716990b323c..e39ccf30b136 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -130,7 +130,7 @@ void main() { .split(' '), null), ProcessCall( - 'gcloud', 'config set project flutter-infra'.split(' '), null), + 'gcloud', 'config set project flutter-cirrus'.split(' '), null), ProcessCall( '/packages/plugin1/example/android/gradlew', 'app:assembleAndroidTest -Pverbose=true'.split(' '), @@ -207,7 +207,7 @@ void main() { .split(' '), null), ProcessCall( - 'gcloud', 'config set project flutter-infra'.split(' '), null), + 'gcloud', 'config set project flutter-cirrus'.split(' '), null), ProcessCall( '/packages/plugin/example/android/gradlew', 'app:assembleAndroidTest -Pverbose=true'.split(' '), @@ -433,7 +433,7 @@ void main() { .split(' '), null), ProcessCall( - 'gcloud', 'config set project flutter-infra'.split(' '), null), + 'gcloud', 'config set project flutter-cirrus'.split(' '), null), ProcessCall( '/packages/plugin/example/android/gradlew', 'app:assembleAndroidTest -Pverbose=true'.split(' '), @@ -588,7 +588,7 @@ void main() { .split(' '), null), ProcessCall( - 'gcloud', 'config set project flutter-infra'.split(' '), null), + 'gcloud', 'config set project flutter-cirrus'.split(' '), null), ProcessCall( '/packages/plugin/example/android/gradlew', 'app:assembleAndroidTest -Pverbose=true -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' From d217642a428985b1a6e95a66354a47ebf872fedb Mon Sep 17 00:00:00 2001 From: reez12g Date: Wed, 15 Sep 2021 19:02:04 +0900 Subject: [PATCH 282/364] [image_picker] Update README for image_picker's retrieveLostData() to support multiple files (#4336) --- packages/image_picker/image_picker/CHANGELOG.md | 4 ++++ packages/image_picker/image_picker/README.md | 14 ++++---------- packages/image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 5dc260993773..de815b1ff302 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.4+1 + +* Fix README Example for `ImagePickerCache` to cache multiple files. + ## 0.8.4 * Update `ImagePickerCache` to cache multiple files. diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index 7499c356f3aa..fc4813d1cee5 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -62,14 +62,10 @@ Future getLostData() async { if (response.isEmpty) { return; } - if (response.file != null) { - setState(() { - if (response.type == RetrieveType.video) { - _handleVideo(response.file); - } else { - _handleImage(response.file); - } - }); + if (response.files != null) { + for(final XFile file in response.files) { + _handleFile(file); + } } else { _handleError(response.exception); } @@ -78,8 +74,6 @@ Future getLostData() async { There's no way to detect when this happens, so calling this method at the right place is essential. We recommend to wire this into some kind of start up check. Please refer to the example app to see how we used it. -On Android, `retrieveLostData` will only get the last picked image when picking multiple images, see: [#84634](https://github.com/flutter/flutter/issues/84634). - ## Migrating to 0.8.2+ Starting with version **0.8.2** of the image_picker plugin, new methods have been added for picking files that return `XFile` instances (from the [cross_file](https://pub.dev/packages/cross_file) package) rather than the plugin's own `PickedFile` instances. While the previous methods still exist, it is already recommended to start migrating over to their new equivalents. Eventually, `PickedFile` and the methods that return instances of it will be deprecated and removed. diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 3bbcfe99882e..fca821be2d5f 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.4 +version: 0.8.4+1 environment: sdk: ">=2.12.0 <3.0.0" From edc899e7384c8e4e0e5848a717e0a59abe63ac5a Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Wed, 15 Sep 2021 06:49:15 -0700 Subject: [PATCH 283/364] [ci] Grant contents: write permission to release.yml (#4350) --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fbe499f0963..a64acf7692f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,9 @@ jobs: release: if: github.repository_owner == 'flutter' name: release + permissions: + # Release needs to push a tag back to the repo. + contents: write runs-on: ubuntu-latest steps: - name: "Install Flutter" From bb3ebb2b79eb856f641398b7776c52539acfce04 Mon Sep 17 00:00:00 2001 From: Bruno Bowden Date: Wed, 15 Sep 2021 09:47:03 -0700 Subject: [PATCH 284/364] [multiple] Java 8 target for all plugins with -Werror compiler arg (#3216) --- packages/android_alarm_manager/android/build.gradle | 8 ++++---- packages/android_intent/CHANGELOG.md | 1 + packages/android_intent/android/build.gradle | 6 ++++-- packages/camera/camera/android/build.gradle | 4 ++-- packages/connectivity/connectivity/CHANGELOG.md | 1 + .../connectivity/connectivity/android/build.gradle | 4 ++++ packages/path_provider/path_provider/CHANGELOG.md | 3 ++- .../path_provider/path_provider/android/build.gradle | 8 +++----- packages/path_provider/path_provider/pubspec.yaml | 2 +- packages/video_player/video_player/CHANGELOG.md | 4 ++++ .../video_player/video_player/android/build.gradle | 8 +++----- .../video_player/example/android/app/build.gradle | 11 ++++------- packages/video_player/video_player/pubspec.yaml | 2 +- 13 files changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/android_alarm_manager/android/build.gradle b/packages/android_alarm_manager/android/build.gradle index b173137786a9..7712ed56fe6f 100644 --- a/packages/android_alarm_manager/android/build.gradle +++ b/packages/android_alarm_manager/android/build.gradle @@ -28,10 +28,6 @@ apply plugin: 'com.android.library' android { compileSdkVersion 29 - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } defaultConfig { minSdkVersion 16 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -54,6 +50,10 @@ android { } } } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } dependencies { diff --git a/packages/android_intent/CHANGELOG.md b/packages/android_intent/CHANGELOG.md index 82cd5db3e4e4..79eafe70e821 100644 --- a/packages/android_intent/CHANGELOG.md +++ b/packages/android_intent/CHANGELOG.md @@ -2,6 +2,7 @@ * Remove references to the V1 Android embedding. * Updated Android lint settings. +* Specify Java 8 for Android build. ## 2.0.2 diff --git a/packages/android_intent/android/build.gradle b/packages/android_intent/android/build.gradle index e8b9f3810146..f0af1602dbb1 100644 --- a/packages/android_intent/android/build.gradle +++ b/packages/android_intent/android/build.gradle @@ -37,8 +37,10 @@ android { disable 'InvalidPackage' disable 'GradleDependency' } - - + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } testOptions { unitTests.includeAndroidResources = true unitTests.returnDefaultValues = true diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle index 9bbafb653ef8..61d13e5579cc 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -39,8 +39,8 @@ android { baseline file("lint-baseline.xml") } compileOptions { - sourceCompatibility = '1.8' - targetCompatibility = '1.8' + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } diff --git a/packages/connectivity/connectivity/CHANGELOG.md b/packages/connectivity/connectivity/CHANGELOG.md index f5489692bee9..932565842efd 100644 --- a/packages/connectivity/connectivity/CHANGELOG.md +++ b/packages/connectivity/connectivity/CHANGELOG.md @@ -2,6 +2,7 @@ * Remove references to the Android V1 embedding. * Updated Android lint settings. +* Specify Java 8 for Android build. ## 3.0.6 diff --git a/packages/connectivity/connectivity/android/build.gradle b/packages/connectivity/connectivity/android/build.gradle index 983f29b142de..e1ba0c7c892e 100644 --- a/packages/connectivity/connectivity/android/build.gradle +++ b/packages/connectivity/connectivity/android/build.gradle @@ -50,4 +50,8 @@ android { } } } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } diff --git a/packages/path_provider/path_provider/CHANGELOG.md b/packages/path_provider/path_provider/CHANGELOG.md index ba7bb3dc7ada..f3f3b2e4da12 100644 --- a/packages/path_provider/path_provider/CHANGELOG.md +++ b/packages/path_provider/path_provider/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 2.0.4 * Updated Android lint settings. +* Specify Java 8 for Android build. ## 2.0.3 diff --git a/packages/path_provider/path_provider/android/build.gradle b/packages/path_provider/path_provider/android/build.gradle index 3458140bd0eb..1a22f135fe5a 100644 --- a/packages/path_provider/path_provider/android/build.gradle +++ b/packages/path_provider/path_provider/android/build.gradle @@ -32,11 +32,9 @@ android { disable 'InvalidPackage' disable 'GradleDependency' } - android { - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } diff --git a/packages/path_provider/path_provider/pubspec.yaml b/packages/path_provider/path_provider/pubspec.yaml index 10352e44c452..febfef516f85 100644 --- a/packages/path_provider/path_provider/pubspec.yaml +++ b/packages/path_provider/path_provider/pubspec.yaml @@ -2,7 +2,7 @@ name: path_provider description: Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. repository: https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+path_provider%22 -version: 2.0.3 +version: 2.0.4 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index a82455231ecd..b0227fe83611 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.1 + +* Specify Java 8 for Android build. + ## 2.2.0 * Add `contentUri` based VideoPlayerController. diff --git a/packages/video_player/video_player/android/build.gradle b/packages/video_player/video_player/android/build.gradle index 9d9984439370..5d6b737f47a5 100644 --- a/packages/video_player/video_player/android/build.gradle +++ b/packages/video_player/video_player/android/build.gradle @@ -37,11 +37,9 @@ android { disable 'InvalidPackage' disable 'GradleDependency' } - android { - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } dependencies { diff --git a/packages/video_player/video_player/example/android/app/build.gradle b/packages/video_player/video_player/example/android/app/build.gradle index fc0673ad1bd8..0d1d5031ef4f 100644 --- a/packages/video_player/video_player/example/android/app/build.gradle +++ b/packages/video_player/video_player/example/android/app/build.gradle @@ -30,6 +30,10 @@ android { lintOptions { disable 'InvalidPackage' } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } defaultConfig { applicationId "io.flutter.plugins.videoplayerexample" @@ -40,13 +44,6 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - android { - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } - } - buildTypes { release { // TODO: Add your own signing config for the release build. diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 86eee3c9bf42..658357b8fbb1 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.2.0 +version: 2.2.1 environment: sdk: ">=2.12.0 <3.0.0" From af758d5d643af092c69d79467108d5cf006dab6d Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 16 Sep 2021 01:53:08 +0200 Subject: [PATCH 285/364] [video_player] Fix a disposed `VideoPlayerController` throwing an exception when being replaced in the `VideoPlayer` (#4344) * fix: do not removeListener if VideoPlayerController is already disposed Co-authored-by: David Iglesias Teixeira --- .../video_player/video_player/CHANGELOG.md | 4 + .../controller_swap_test.dart | 92 +++++++++++++++++++ .../video_player/lib/video_player.dart | 9 ++ .../video_player/video_player/pubspec.yaml | 2 +- 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/video_player/video_player/example/integration_test/controller_swap_test.dart diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index b0227fe83611..134b2dc1cbf5 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.2 + +* Fix a disposed `VideoPlayerController` throwing an exception when being replaced in the `VideoPlayer`. + ## 2.2.1 * Specify Java 8 for Android build. diff --git a/packages/video_player/video_player/example/integration_test/controller_swap_test.dart b/packages/video_player/video_player/example/integration_test/controller_swap_test.dart new file mode 100644 index 000000000000..cae51767f4aa --- /dev/null +++ b/packages/video_player/video_player/example/integration_test/controller_swap_test.dart @@ -0,0 +1,92 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +const Duration _playDuration = Duration(seconds: 1); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( + 'can substitute one controller by another without crashing', + (WidgetTester tester) async { + VideoPlayerController controller = VideoPlayerController.network( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + ); + VideoPlayerController another = VideoPlayerController.network( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + ); + await controller.initialize(); + await another.initialize(); + await controller.setVolume(0); + await another.setVolume(0); + + final Completer started = Completer(); + final Completer ended = Completer(); + bool startedBuffering = false; + bool endedBuffering = false; + + another.addListener(() { + if (another.value.isBuffering && !startedBuffering) { + startedBuffering = true; + started.complete(); + } + if (startedBuffering && !another.value.isBuffering && !endedBuffering) { + endedBuffering = true; + ended.complete(); + } + }); + + // Inject a widget with `controller`... + await tester.pumpWidget(renderVideoWidget(controller)); + await controller.play(); + await tester.pumpAndSettle(_playDuration); + await controller.pause(); + + // Disposing controller causes the Widget to crash in the next line + // (Issue https://github.com/flutter/flutter/issues/90046) + await controller.dispose(); + + // Now replace it with `another` controller... + await tester.pumpWidget(renderVideoWidget(another)); + await another.play(); + await another.seekTo(const Duration(seconds: 5)); + await tester.pumpAndSettle(_playDuration); + await another.pause(); + + // Expect that `another` played. + expect(another.value.position, + (Duration position) => position > const Duration(seconds: 0)); + + await started; + expect(startedBuffering, true); + + await ended; + expect(endedBuffering, true); + }, + skip: !(kIsWeb || defaultTargetPlatform == TargetPlatform.android), + ); +} + +Widget renderVideoWidget(VideoPlayerController controller) { + return Material( + elevation: 0, + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: AspectRatio( + key: Key('same'), + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ), + ), + ); +} diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 685563ae12c3..0d682c9b18e4 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -594,6 +594,15 @@ class VideoPlayerController extends ValueNotifier { value = value.copyWith(caption: _getCaptionAt(position)); } + @override + void removeListener(VoidCallback listener) { + // Prevent VideoPlayer from causing an exception to be thrown when attempting to + // remove its own listener after the controller has already been disposed. + if (!_isDisposed) { + super.removeListener(listener); + } + } + bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized; } diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 658357b8fbb1..2fa5d663d3c7 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.2.1 +version: 2.2.2 environment: sdk: ">=2.12.0 <3.0.0" From 5d64092352e885c82a306cd9c04672fe31e2640c Mon Sep 17 00:00:00 2001 From: Koen Van Looveren Date: Thu, 16 Sep 2021 03:02:07 +0200 Subject: [PATCH 286/364] [video_player] bugfix caption still showing when text is empty (#3374) --- packages/video_player/video_player/CHANGELOG.md | 4 ++++ .../video_player/lib/video_player.dart | 14 ++++++++------ packages/video_player/video_player/pubspec.yaml | 2 +- .../video_player/test/video_player_test.dart | 5 +++++ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 134b2dc1cbf5..69ad92c13fe8 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.3 + +* Fixed empty caption text still showing the caption widget. + ## 2.2.2 * Fix a disposed `VideoPlayerController` throwing an exception when being replaced in the `VideoPlayer`. diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 0d682c9b18e4..fe3437593a81 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -946,11 +946,12 @@ class ClosedCaption extends StatelessWidget { /// Creates a a new closed caption, designed to be used with /// [VideoPlayerValue.caption]. /// - /// If [text] is null, nothing will be displayed. + /// If [text] is null or empty, nothing will be displayed. const ClosedCaption({Key? key, this.text, this.textStyle}) : super(key: key); /// The text that will be shown in the closed caption, or null if no caption /// should be shown. + /// If the text is empty the caption will not be shown. final String? text; /// Specifies how the text in the closed caption should look. @@ -961,16 +962,17 @@ class ClosedCaption extends StatelessWidget { @override Widget build(BuildContext context) { + final text = this.text; + if (text == null || text.isEmpty) { + return SizedBox.shrink(); + } + final TextStyle effectiveTextStyle = textStyle ?? DefaultTextStyle.of(context).style.copyWith( fontSize: 36.0, color: Colors.white, ); - if (text == null) { - return SizedBox.shrink(); - } - return Align( alignment: Alignment.bottomCenter, child: Padding( @@ -982,7 +984,7 @@ class ClosedCaption extends StatelessWidget { ), child: Padding( padding: EdgeInsets.symmetric(horizontal: 2.0), - child: Text(text!, style: effectiveTextStyle), + child: Text(text, style: effectiveTextStyle), ), ), ), diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 2fa5d663d3c7..317d46d851b7 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.2.2 +version: 2.2.3 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index 5fdc1fbf4574..a929c6827fd0 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -162,6 +162,11 @@ void main() { expect(find.byType(Text), findsNothing); }); + testWidgets('handles empty text', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: ClosedCaption(text: ''))); + expect(find.byType(Text), findsNothing); + }); + testWidgets('Passes text contrast ratio guidelines', (WidgetTester tester) async { final String text = 'foo'; From c13218faba5168ef9067502b3e176a600bf4bf5a Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 16 Sep 2021 03:52:06 +0200 Subject: [PATCH 287/364] [camera_web] Release the camera stream used to request video and audio permissions (#4342) --- packages/camera/camera_web/CHANGELOG.md | 4 +++ packages/camera/camera_web/README.md | 2 +- .../integration_test/camera_web_test.dart | 27 +++++++++++++++++++ .../camera/camera_web/lib/src/camera_web.dart | 5 +++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index b116636c2808..098fe62c3a1f 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fix cameraNotReadable error that prevented access to the camera on some Android devices. + ## 0.2.0 * Initial release, adapted from the Flutter [I/O Photobooth](https://photobooth.flutter.dev/) project. diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md index c6e1e0f13cab..032a345fad99 100644 --- a/packages/camera/camera_web/README.md +++ b/packages/camera/camera_web/README.md @@ -9,7 +9,7 @@ The web implementation of [`camera`][camera]. ### Depend on the package This package is not an [endorsed implementation](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin) -of the google_maps_flutter plugin yet, so you'll need to [add it explicitly](https://pub.dev/packages/camera_web/install). +of the camera plugin yet, so you'll need to [add it explicitly](https://pub.dev/packages/camera_web/install). ## Example diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 4bc10badab05..f469f3c30849 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -110,6 +110,33 @@ void main() { ).called(1); }); + testWidgets( + 'releases the camera stream ' + 'used to request video and audio permissions', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + var videoTrackStopped = false; + when(videoTrack.stop).thenAnswer((_) { + videoTrackStopped = true; + }); + + when( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), + ), + ), + ).thenAnswer( + (_) => Future.value( + FakeMediaStream([videoTrack]), + ), + ); + + final _ = await CameraPlatform.instance.availableCameras(); + + expect(videoTrackStopped, isTrue); + }); + testWidgets( 'gets a video stream ' 'for a video input device', (tester) async { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 5c976b8f8657..92c43c45b6b9 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -89,12 +89,15 @@ class CameraPlugin extends CameraPlatform { } // Request video and audio permissions. - await _cameraService.getMediaStreamForOptions( + final cameraStream = await _cameraService.getMediaStreamForOptions( CameraOptions( audio: AudioConstraints(enabled: true), ), ); + // Release the camera stream used to request video and audio permissions. + cameraStream.getVideoTracks().forEach((videoTrack) => videoTrack.stop()); + // Request available media devices. final devices = await mediaDevices.enumerateDevices(); From 8009a2bb12de53d105c019b052df5bd6ff06b3c3 Mon Sep 17 00:00:00 2001 From: Tim Sneath Date: Thu, 16 Sep 2021 09:57:06 -0700 Subject: [PATCH 288/364] Uncomment Marker icons now that ImageListener API change has landed in stable (#2443) --- .../example/lib/place_marker.dart | 95 +++++++++---------- 1 file changed, 43 insertions(+), 52 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart index 4e9f4c14ebd0..3d083e5f9fa9 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'dart:math'; import 'dart:ui'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -245,42 +246,36 @@ class PlaceMarkerBodyState extends State { }); } -// A breaking change to the ImageStreamListener API affects this sample. -// I've updates the sample to use the new API, but as we cannot use the new -// API before it makes it to stable I'm commenting out this sample for now -// TODO(amirh): uncomment this one the ImageStream API change makes it to stable. -// https://github.com/flutter/flutter/issues/33438 -// -// void _setMarkerIcon(BitmapDescriptor assetIcon) { -// if (selectedMarker == null) { -// return; -// } -// -// final Marker marker = markers[selectedMarker]; -// setState(() { -// markers[selectedMarker] = marker.copyWith( -// iconParam: assetIcon, -// ); -// }); -// } -// -// Future _getAssetIcon(BuildContext context) async { -// final Completer bitmapIcon = -// Completer(); -// final ImageConfiguration config = createLocalImageConfiguration(context); -// -// const AssetImage('assets/red_square.png') -// .resolve(config) -// .addListener(ImageStreamListener((ImageInfo image, bool sync) async { -// final ByteData bytes = -// await image.image.toByteData(format: ImageByteFormat.png); -// final BitmapDescriptor bitmap = -// BitmapDescriptor.fromBytes(bytes.buffer.asUint8List()); -// bitmapIcon.complete(bitmap); -// })); -// -// return await bitmapIcon.future; -// } + void _setMarkerIcon(MarkerId markerId, BitmapDescriptor assetIcon) { + final Marker marker = markers[markerId]!; + setState(() { + markers[markerId] = marker.copyWith( + iconParam: assetIcon, + ); + }); + } + + Future _getAssetIcon(BuildContext context) async { + final Completer bitmapIcon = + Completer(); + final ImageConfiguration config = createLocalImageConfiguration(context); + + const AssetImage('assets/red_square.png') + .resolve(config) + .addListener(ImageStreamListener((ImageInfo image, bool sync) async { + final ByteData? bytes = + await image.image.toByteData(format: ImageByteFormat.png); + if (bytes == null) { + bitmapIcon.completeError(Exception('Unable to encode icon')); + return; + } + final BitmapDescriptor bitmap = + BitmapDescriptor.fromBytes(bytes.buffer.asUint8List()); + bitmapIcon.complete(bitmap); + })); + + return await bitmapIcon.future; + } @override Widget build(BuildContext context) { @@ -386,22 +381,18 @@ class PlaceMarkerBodyState extends State { ? null : () => _changeZIndex(selectedId), ), - // A breaking change to the ImageStreamListener API affects this sample. - // I've updates the sample to use the new API, but as we cannot use the new - // API before it makes it to stable I'm commenting out this sample for now - // TODO(amirh): uncomment this one the ImageStream API change makes it to stable. - // https://github.com/flutter/flutter/issues/33438 - // - // TextButton( - // child: const Text('set marker icon'), - // onPressed: () { - // _getAssetIcon(context).then( - // (BitmapDescriptor icon) { - // _setMarkerIcon(icon); - // }, - // ); - // }, - // ), + TextButton( + child: const Text('set marker icon'), + onPressed: selectedId == null + ? null + : () { + _getAssetIcon(context).then( + (BitmapDescriptor icon) { + _setMarkerIcon(selectedId, icon); + }, + ); + }, + ), ], ), ], From aa69736c460c60899eeb1dbddc573a929198f7bd Mon Sep 17 00:00:00 2001 From: Anton Borries Date: Thu, 16 Sep 2021 23:06:55 +0200 Subject: [PATCH 289/364] [camera_web] Add video recording functionality (#4210) * feat: Add Support for Video Recording in Camera Web * docs: add video recording documentation Co-authored-by: Bartosz Selwesiuk --- .../camera/lib/src/camera_controller.dart | 2 +- packages/camera/camera_web/CHANGELOG.md | 3 +- packages/camera/camera_web/README.md | 17 +- .../camera_error_code_test.dart | 7 + .../example/integration_test/camera_test.dart | 692 +++++++++++++++++- .../integration_test/camera_web_test.dart | 623 +++++++++++++++- .../integration_test/helpers/mocks.dart | 30 + .../camera/camera_web/lib/src/camera.dart | 258 ++++++- .../camera/camera_web/lib/src/camera_web.dart | 65 +- .../lib/src/types/camera_error_code.dart | 4 + packages/camera/camera_web/pubspec.yaml | 2 +- 11 files changed, 1622 insertions(+), 81 deletions(-) diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index f21a3b12c81f..8cf1e90e36c1 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -335,7 +335,7 @@ class CameraController extends ValueNotifier { /// Preparing audio can cause a minor delay in the CameraPreview view on iOS. /// If video recording is intended, calling this early eliminates this delay /// that would otherwise be experienced when video recording is started. - /// This operation is a no-op on Android. + /// This operation is a no-op on Android and Web. /// /// Throws a [CameraException] if the prepare fails. Future prepareForVideoRecording() async { diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 098fe62c3a1f..8596b3595852 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.2.1 +* Add video recording functionality. * Fix cameraNotReadable error that prevented access to the camera on some Android devices. ## 0.2.0 diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md index 032a345fad99..918e695496a4 100644 --- a/packages/camera/camera_web/README.md +++ b/packages/camera/camera_web/README.md @@ -83,11 +83,24 @@ if (kIsWeb) { } ``` +### Video recording + +The video recording implementation is backed by [MediaRecorder Web API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder) with the following [browser support](https://caniuse.com/mdn-api_mediarecorder): + +![Data on support for the MediaRecorder feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/mediarecorder.png). + +A video is recorded in one of the following video MIME types: +- video/webm (e.g. on Chrome or Firefox) +- video/mp4 (e.g. on Safari) + +Pausing, resuming or stopping the video recording throws a `PlatformException` with the `videoRecordingNotStarted` error code if the video recording was not started. + +For the browsers that do not support the video recording: +- `CameraPlatform.startVideoRecording` throws a `PlatformException` with the `notSupported` error code. + ## Missing implementation The web implementation of [`camera`][camera] is missing the following features: - -- Video recording ([in progress](https://github.com/flutter/plugins/pull/4210)) - Exposure mode, point and offset - Focus mode and point - Sensor orientation diff --git a/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart index d0250c6e4e26..a298b57dfd7f 100644 --- a/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart @@ -113,6 +113,13 @@ void main() { ); }); + testWidgets('videoRecordingNotStarted', (tester) async { + expect( + CameraErrorCode.videoRecordingNotStarted.toString(), + equals('videoRecordingNotStarted'), + ); + }); + testWidgets('unknown', (tester) async { expect( CameraErrorCode.unknown.toString(), diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 712d8c77ff3e..3a25e33c5398 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.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:html'; import 'dart:ui'; @@ -531,7 +532,7 @@ void main() { ).called(1); }); - group('throws CameraWebException', () { + group('throws a CameraWebException', () { testWidgets( 'with torchModeNotSupported error ' 'when there are no media devices', (tester) async { @@ -774,7 +775,7 @@ void main() { ).called(1); }); - group('throws CameraWebException', () { + group('throws a CameraWebException', () { testWidgets( 'with zoomLevelInvalid error ' 'when the provided zoom level is below minimum', (tester) async { @@ -827,20 +828,21 @@ void main() { .thenReturn(zoomLevelCapability); expect( - () => camera.setZoomLevel(105.0), - throwsA( - isA() - .having( - (e) => e.cameraId, - 'cameraId', - textureId, - ) - .having( - (e) => e.code, - 'code', - CameraErrorCode.zoomLevelInvalid, - ), - )); + () => camera.setZoomLevel(105.0), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelInvalid, + ), + ), + ); }); }); }); @@ -943,6 +945,503 @@ void main() { }); }); + group('video recording', () { + const supportedVideoType = 'video/webm'; + + late MediaRecorder mediaRecorder; + + bool isVideoTypeSupported(String type) => type == supportedVideoType; + + setUp(() { + mediaRecorder = MockMediaRecorder(); + + when(() => mediaRecorder.onError) + .thenAnswer((_) => const Stream.empty()); + }); + + group('startVideoRecording', () { + testWidgets( + 'creates a media recorder ' + 'with appropriate options', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + expect( + camera.mediaRecorder!.stream, + equals(camera.stream), + ); + + expect( + camera.mediaRecorder!.mimeType, + equals(supportedVideoType), + ); + + expect( + camera.mediaRecorder!.state, + equals('recording'), + ); + }); + + testWidgets('listens to the media recorder data events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + verify( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).called(1); + }); + + testWidgets('listens to the media recorder stop events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + verify( + () => mediaRecorder.addEventListener('stop', any()), + ).called(1); + }); + + testWidgets('starts a video recording', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + verify(mediaRecorder.start).called(1); + }); + + testWidgets( + 'starts a video recording ' + 'with maxVideoDuration', (tester) async { + const maxVideoDuration = Duration(hours: 1); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + verify(() => mediaRecorder.start(maxVideoDuration.inMilliseconds)) + .called(1); + }); + + group('throws a CameraWebException', () { + testWidgets( + 'with notSupported error ' + 'when maxVideoDuration is 0 milliseconds or less', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + expect( + () => camera.startVideoRecording(maxVideoDuration: Duration.zero), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported, + ), + ), + ); + }); + + testWidgets( + 'with notSupported error ' + 'when no video types are supported', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..isVideoTypeSupported = (type) => false; + + await camera.initialize(); + await camera.play(); + + expect( + camera.startVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported, + ), + ), + ); + }); + }); + }); + + group('pauseVideoRecording', () { + testWidgets('pauses a video recording', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..mediaRecorder = mediaRecorder; + + await camera.pauseVideoRecording(); + + verify(mediaRecorder.pause).called(1); + }); + + testWidgets( + 'throws a CameraWebException ' + 'with videoRecordingNotStarted error ' + 'if the video recording was not started', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.pauseVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('resumeVideoRecording', () { + testWidgets('resumes a video recording', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..mediaRecorder = mediaRecorder; + + await camera.resumeVideoRecording(); + + verify(mediaRecorder.resume).called(1); + }); + + testWidgets( + 'throws a CameraWebException ' + 'with videoRecordingNotStarted error ' + 'if the video recording was not started', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.resumeVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('stopVideoRecording', () { + testWidgets( + 'stops a video recording and ' + 'returns the captured file ' + 'based on all video data parts', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + late void Function(Event) videoDataAvailableListener; + late void Function(Event) videoRecordingStoppedListener; + + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + + Blob? finalVideo; + List? videoParts; + camera.blobBuilder = (blobs, videoType) { + videoParts = [...blobs]; + finalVideo = Blob(blobs, videoType); + return finalVideo!; + }; + + await camera.startVideoRecording(); + final videoFileFuture = camera.stopVideoRecording(); + + final capturedVideoPartOne = Blob([]); + final capturedVideoPartTwo = Blob([]); + + final capturedVideoParts = [ + capturedVideoPartOne, + capturedVideoPartTwo, + ]; + + videoDataAvailableListener + ..call(FakeBlobEvent(capturedVideoPartOne)) + ..call(FakeBlobEvent(capturedVideoPartTwo)); + + videoRecordingStoppedListener.call(Event('stop')); + + final videoFile = await videoFileFuture; + + verify(mediaRecorder.stop).called(1); + + expect( + videoFile, + isNotNull, + ); + + expect( + videoFile.mimeType, + equals(supportedVideoType), + ); + + expect( + videoFile.name, + equals(finalVideo.hashCode.toString()), + ); + + expect( + videoParts, + equals(capturedVideoParts), + ); + }); + + testWidgets( + 'throws a CameraWebException ' + 'with videoRecordingNotStarted error ' + 'if the video recording was not started', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.stopVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('on video data available', () { + late void Function(Event) videoDataAvailableListener; + + setUp(() { + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + }); + + testWidgets( + 'stops a video recording ' + 'if maxVideoDuration is given and ' + 'the recording was not stopped manually', (tester) async { + const maxVideoDuration = Duration(hours: 1); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + when(() => mediaRecorder.state).thenReturn('recording'); + + videoDataAvailableListener.call(FakeBlobEvent(Blob([]))); + + await Future.microtask(() {}); + + verify(mediaRecorder.stop).called(1); + }); + }); + + group('on video recording stopped', () { + late void Function(Event) videoRecordingStoppedListener; + + setUp(() { + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + }); + + testWidgets('stops listening to the media recorder data events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + verify( + () => mediaRecorder.removeEventListener('dataavailable', any()), + ).called(1); + }); + + testWidgets('stops listening to the media recorder stop events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + verify( + () => mediaRecorder.removeEventListener('stop', any()), + ).called(1); + }); + + testWidgets('stops listening to the media recorder errors', + (tester) async { + final onErrorStreamController = StreamController(); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + when(() => mediaRecorder.onError) + .thenAnswer((_) => onErrorStreamController.stream); + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + expect( + onErrorStreamController.hasListener, + isFalse, + ); + }); + }); + }); + group('dispose', () { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( @@ -951,14 +1450,143 @@ void main() { ); await camera.initialize(); - await camera.dispose(); expect(camera.videoElement.srcObject, isNull); }); + + testWidgets('closes the onEnded stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.onEndedController.isClosed, + isTrue, + ); + }); + + testWidgets('closes the onVideoRecordedEvent stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.videoRecorderController.isClosed, + isTrue, + ); + }); + + testWidgets('closes the onVideoRecordingError stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.videoRecordingErrorController.isClosed, + isTrue, + ); + }); }); group('events', () { + group('onVideoRecordedEvent', () { + testWidgets( + 'emits a VideoRecordedEvent ' + 'when a video recording is created', (tester) async { + const maxVideoDuration = Duration(hours: 1); + const supportedVideoType = 'video/webm'; + + final mediaRecorder = MockMediaRecorder(); + when(() => mediaRecorder.onError) + .thenAnswer((_) => const Stream.empty()); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = (type) => type == 'video/webm'; + + await camera.initialize(); + await camera.play(); + + late void Function(Event) videoDataAvailableListener; + late void Function(Event) videoRecordingStoppedListener; + + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + + final streamQueue = StreamQueue(camera.onVideoRecordedEvent); + + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + Blob? finalVideo; + camera.blobBuilder = (blobs, videoType) { + finalVideo = Blob(blobs, videoType); + return finalVideo!; + }; + + videoDataAvailableListener.call(FakeBlobEvent(Blob([]))); + videoRecordingStoppedListener.call(Event('stop')); + + expect( + await streamQueue.next, + equals( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.file, + 'file', + isA() + .having( + (f) => f.mimeType, + 'mimeType', + supportedVideoType, + ) + .having( + (f) => f.name, + 'name', + finalVideo.hashCode.toString(), + ), + ) + .having( + (e) => e.maxVideoDuration, + 'maxVideoDuration', + maxVideoDuration, + ), + ), + ); + + await streamQueue.cancel(); + }); + }); + group('onEnded', () { testWidgets( 'emits the default video track ' @@ -1009,22 +1637,40 @@ void main() { await streamQueue.cancel(); }); + }); + group('onVideoRecordingError', () { testWidgets( - 'no longer emits the default video track ' - 'when the camera is disposed', (tester) async { + 'emits an ErrorEvent ' + 'when the media recorder fails ' + 'when recording a video', (tester) async { + final mediaRecorder = MockMediaRecorder(); + final errorController = StreamController(); + final camera = Camera( textureId: textureId, cameraService: cameraService, - ); + )..mediaRecorder = mediaRecorder; + + when(() => mediaRecorder.onError) + .thenAnswer((_) => errorController.stream); + + final streamQueue = StreamQueue(camera.onVideoRecordingError); await camera.initialize(); - await camera.dispose(); + await camera.play(); + + await camera.startVideoRecording(); + + final errorEvent = ErrorEvent('type'); + errorController.add(errorEvent); expect( - camera.onEndedStreamController.isClosed, - isTrue, + await streamQueue.next, + equals(errorEvent), ); + + await streamQueue.cancel(); }); }); }); diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index f469f3c30849..9749559ed8c6 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -1019,43 +1019,377 @@ void main() { }); }); - testWidgets('prepareForVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.prepareForVideoRecording(), - throwsUnimplementedError, - ); - }); + group('startVideoRecording', () { + late Camera camera; - testWidgets('startVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.startVideoRecording(cameraId), - throwsUnimplementedError, - ); + setUp(() { + camera = MockCamera(); + + when(camera.startVideoRecording).thenAnswer((_) async {}); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => const Stream.empty()); + }); + + testWidgets('starts a video recording', (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + + verify(camera.startVideoRecording).called(1); + }); + + testWidgets('listens to the onVideoRecordingError stream', + (tester) async { + final videoRecordingErrorController = StreamController(); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + + expect( + videoRecordingErrorController.hasListener, + isTrue, + ); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when startVideoRecording throws DomException', + (tester) async { + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.startVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when startVideoRecording throws CameraWebException', + (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.startVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('stopVideoRecording throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.stopVideoRecording(cameraId), - throwsUnimplementedError, - ); + group('stopVideoRecording', () { + testWidgets('stops a video recording', (tester) async { + final camera = MockCamera(); + final capturedVideo = MockXFile(); + + when(camera.stopVideoRecording) + .thenAnswer((_) => Future.value(capturedVideo)); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final video = + await CameraPlatform.instance.stopVideoRecording(cameraId); + + verify(camera.stopVideoRecording).called(1); + + expect(video, capturedVideo); + }); + + testWidgets('stops listening to the onVideoRecordingError stream', + (tester) async { + final camera = MockCamera(); + final videoRecordingErrorController = StreamController(); + + when(camera.startVideoRecording).thenAnswer((_) async => {}); + + when(camera.stopVideoRecording) + .thenAnswer((_) => Future.value(MockXFile())); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + final _ = await CameraPlatform.instance.stopVideoRecording(cameraId); + + expect( + videoRecordingErrorController.hasListener, + isFalse, + ); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when stopVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.stopVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when stopVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.stopVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('pauseVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.pauseVideoRecording(cameraId), - throwsUnimplementedError, - ); + group('pauseVideoRecording', () { + testWidgets('pauses a video recording', (tester) async { + final camera = MockCamera(); + + when(camera.pauseVideoRecording).thenAnswer((_) async {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.pauseVideoRecording(cameraId); + + verify(camera.pauseVideoRecording).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when pauseVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.pauseVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when pauseVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.pauseVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('resumeVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.resumeVideoRecording(cameraId), - throwsUnimplementedError, - ); + group('resumeVideoRecording', () { + testWidgets('resumes a video recording', (tester) async { + final camera = MockCamera(); + + when(camera.resumeVideoRecording).thenAnswer((_) async {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.resumeVideoRecording(cameraId); + + verify(camera.resumeVideoRecording).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when resumeVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.resumeVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when resumeVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.resumeVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); group('setFlashMode', () { @@ -1676,6 +2010,7 @@ void main() { late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; + late StreamController videoRecordingErrorController; setUp(() { camera = MockCamera(); @@ -1684,6 +2019,7 @@ void main() { errorStreamController = StreamController(); abortStreamController = StreamController(); endedStreamController = StreamController(); + videoRecordingErrorController = StreamController(); when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); @@ -1698,6 +2034,11 @@ void main() { when(() => camera.onEnded) .thenAnswer((_) => endedStreamController.stream); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + when(camera.startVideoRecording).thenAnswer((_) async {}); }); testWidgets('disposes the correct camera', (tester) async { @@ -1754,6 +2095,18 @@ void main() { expect(endedStreamController.hasListener, isFalse); }); + testWidgets('cancels the camera video recording error subscriptions', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.startVideoRecording(cameraId); + await CameraPlatform.instance.dispose(cameraId); + + expect(videoRecordingErrorController.hasListener, isFalse); + }); + group('throws PlatformException', () { testWidgets( 'with notFound error ' @@ -1832,6 +2185,7 @@ void main() { late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; + late StreamController videoRecordingErrorController; setUp(() { camera = MockCamera(); @@ -1840,6 +2194,7 @@ void main() { errorStreamController = StreamController(); abortStreamController = StreamController(); endedStreamController = StreamController(); + videoRecordingErrorController = StreamController(); when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); @@ -1853,6 +2208,11 @@ void main() { when(() => camera.onEnded) .thenAnswer((_) => endedStreamController.stream); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + when(() => camera.startVideoRecording()).thenAnswer((_) async => {}); }); testWidgets( @@ -2258,13 +2618,210 @@ void main() { await streamQueue.cancel(); }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on startVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => const Stream.empty()); + + when( + () => camera.startVideoRecording( + maxVideoDuration: any(named: 'maxVideoDuration'), + ), + ).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on the camera video recording error event', (tester) async { + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.startVideoRecording(cameraId); + + final errorEvent = FakeErrorEvent('type', 'message'); + + videoRecordingErrorController.add(errorEvent); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on stopVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.stopVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on pauseVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.pauseVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on resumeVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.resumeVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); }); - testWidgets('onVideoRecordedEvent throws UnimplementedError', + testWidgets('onVideoRecordedEvent emits a VideoRecordedEvent', (tester) async { + final camera = MockCamera(); + final capturedVideo = MockXFile(); + final stream = Stream.value( + VideoRecordedEvent(cameraId, capturedVideo, Duration.zero)); + when(() => camera.onVideoRecordedEvent).thenAnswer((_) => stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final streamQueue = + StreamQueue(CameraPlatform.instance.onVideoRecordedEvent(cameraId)); + expect( - () => CameraPlatform.instance.onVideoRecordedEvent(cameraId), - throwsUnimplementedError, + await streamQueue.next, + equals( + VideoRecordedEvent(cameraId, capturedVideo, Duration.zero), + ), ); }); diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index e6a11cc0b454..77e9077356f7 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -41,6 +41,8 @@ class MockXFile extends Mock implements XFile {} class MockJsUtil extends Mock implements JsUtil {} +class MockMediaRecorder extends Mock implements MediaRecorder {} + /// A fake [MediaStream] that returns the provided [_videoTracks]. class FakeMediaStream extends Fake implements MediaStream { FakeMediaStream(this._videoTracks); @@ -122,6 +124,34 @@ class FakeElementStream extends Fake } } +/// A fake [BlobEvent] that returns the provided blob [data]. +class FakeBlobEvent extends Fake implements BlobEvent { + FakeBlobEvent(this._blob); + + final Blob? _blob; + + @override + Blob? get data => _blob; +} + +/// A fake [DomException] that returns the provided error [_name] and [_message]. +class FakeErrorEvent extends Fake implements ErrorEvent { + FakeErrorEvent( + String type, [ + String? message, + ]) : _type = type, + _message = message; + + final String _type; + final String? _message; + + @override + String get type => _type; + + @override + String? get message => _message; +} + /// Returns a video element with a blank stream of size [videoSize]. /// /// Can be used to mock a video stream: diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 4b7a185b90f7..cf0187057188 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -26,8 +26,10 @@ String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; /// the video element in [_applyDefaultVideoStyles]. /// See: https://github.com/flutter/flutter/issues/79519 /// -/// The camera can be played/stopped by calling [play]/[stop] -/// or may capture a picture by calling [takePicture]. +/// The camera stream can be played/stopped by calling [play]/[stop], +/// may capture a picture by calling [takePicture] or capture a video +/// by calling [startVideoRecording], [pauseVideoRecording], +/// [resumeVideoRecording] or [stopVideoRecording]. /// /// The camera zoom may be adjusted with [setZoomLevel]. The provided /// zoom level must be a value in the range of [getMinZoomLevel] to @@ -76,15 +78,31 @@ class Camera { /// /// MediaStreamTrack.onended: /// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/onended - Stream get onEnded => onEndedStreamController.stream; + Stream get onEnded => onEndedController.stream; /// The stream controller for the [onEnded] stream. @visibleForTesting - final onEndedStreamController = - StreamController.broadcast(); + final onEndedController = StreamController.broadcast(); StreamSubscription? _onEndedSubscription; + /// The stream of the camera video recording errors. + /// + /// This occurs when the video recording is not allowed or an unsupported + /// codec is used. + /// + /// MediaRecorder.error: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/error_event + Stream get onVideoRecordingError => + videoRecordingErrorController.stream; + + /// The stream controller for the [onVideoRecordingError] stream. + @visibleForTesting + final videoRecordingErrorController = + StreamController.broadcast(); + + StreamSubscription? _onVideoRecordingErrorSubscription; + /// The camera flash mode. @visibleForTesting FlashMode? flashMode; @@ -96,6 +114,41 @@ class Camera { @visibleForTesting html.Window? window = html.window; + /// The recorder used to record a video from the camera. + @visibleForTesting + html.MediaRecorder? mediaRecorder; + + /// Whether the video of the given type is supported. + @visibleForTesting + bool Function(String) isVideoTypeSupported = + html.MediaRecorder.isTypeSupported; + + /// The list of consecutive video data files recorded with [mediaRecorder]. + List _videoData = []; + + /// Completes when the video recording is stopped/finished. + Completer? _videoAvailableCompleter; + + /// A data listener fired when a new part of video data is available. + void Function(html.Event)? _videoDataAvailableListener; + + /// A listener fired when a video recording is stopped. + void Function(html.Event)? _videoRecordingStoppedListener; + + /// A builder to merge a list of blobs into a single blob. + @visibleForTesting + html.Blob Function(List blobs, String type) blobBuilder = + (blobs, type) => html.Blob(blobs, type); + + /// The stream that emits a [VideoRecordedEvent] when a video recording is created. + Stream get onVideoRecordedEvent => + videoRecorderController.stream; + + /// The stream controller for the [onVideoRecordedEvent] stream. + @visibleForTesting + final StreamController videoRecorderController = + StreamController.broadcast(); + /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. /// Emits the camera default video track on the [onEnded] stream when it ends. @@ -130,7 +183,7 @@ class Camera { final defaultVideoTrack = videoTracks.first; _onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) { - onEndedStreamController.add(defaultVideoTrack); + onEndedController.add(defaultVideoTrack); }); } } @@ -158,7 +211,7 @@ class Camera { void stop() { final videoTracks = stream!.getVideoTracks(); if (videoTracks.isNotEmpty) { - onEndedStreamController.add(videoTracks.first); + onEndedController.add(videoTracks.first); } final tracks = stream?.getTracks(); @@ -365,23 +418,204 @@ class Camera { /// Returns the registered view type of the camera. String getViewType() => _getViewType(textureId); - /// Disposes the camera by stopping the camera stream - /// and reloading the camera source. + /// Starts a new video recording using [html.MediaRecorder]. + /// + /// Throws a [CameraWebException] if the provided maximum video duration is invalid + /// or the browser does not support any of the available video mime types + /// from [_videoMimeType]. + Future startVideoRecording({Duration? maxVideoDuration}) async { + if (maxVideoDuration != null && maxVideoDuration.inMilliseconds <= 0) { + throw CameraWebException( + textureId, + CameraErrorCode.notSupported, + 'The maximum video duration must be greater than 0 milliseconds.', + ); + } + + mediaRecorder ??= html.MediaRecorder(videoElement.srcObject!, { + 'mimeType': _videoMimeType, + }); + + _videoAvailableCompleter = Completer(); + + _videoDataAvailableListener = + (event) => _onVideoDataAvailable(event, maxVideoDuration); + + _videoRecordingStoppedListener = + (event) => _onVideoRecordingStopped(event, maxVideoDuration); + + mediaRecorder!.addEventListener( + 'dataavailable', + _videoDataAvailableListener, + ); + + mediaRecorder!.addEventListener( + 'stop', + _videoRecordingStoppedListener, + ); + + _onVideoRecordingErrorSubscription = + mediaRecorder!.onError.listen((html.Event event) { + final error = event as html.ErrorEvent; + if (error != null) { + videoRecordingErrorController.add(error); + } + }); + + if (maxVideoDuration != null) { + mediaRecorder!.start(maxVideoDuration.inMilliseconds); + } else { + // Don't pass the null duration as that will fire a `dataavailable` event directly. + mediaRecorder!.start(); + } + } + + void _onVideoDataAvailable( + html.Event event, [ + Duration? maxVideoDuration, + ]) { + final blob = (event as html.BlobEvent).data; + + // Append the recorded part of the video to the list of all video data files. + if (blob != null) { + _videoData.add(blob); + } + + // Stop the recorder if the video has a maxVideoDuration + // and the recording was not stopped manually. + if (maxVideoDuration != null && mediaRecorder!.state == 'recording') { + mediaRecorder!.stop(); + } + } + + Future _onVideoRecordingStopped( + html.Event event, [ + Duration? maxVideoDuration, + ]) async { + if (_videoData.isNotEmpty) { + // Concatenate all video data files into a single blob. + final videoType = _videoData.first.type; + final videoBlob = blobBuilder(_videoData, videoType); + + // Create a file containing the video blob. + final file = XFile( + html.Url.createObjectUrl(videoBlob), + mimeType: _videoMimeType, + name: videoBlob.hashCode.toString(), + ); + + // Emit an event containing the recorded video file. + videoRecorderController.add( + VideoRecordedEvent(this.textureId, file, maxVideoDuration), + ); + + _videoAvailableCompleter?.complete(file); + } + + // Clean up the media recorder with its event listeners and video data. + mediaRecorder!.removeEventListener( + 'dataavailable', + _videoDataAvailableListener, + ); + + mediaRecorder!.removeEventListener( + 'stop', + _videoDataAvailableListener, + ); + + await _onVideoRecordingErrorSubscription?.cancel(); + + mediaRecorder = null; + _videoDataAvailableListener = null; + _videoRecordingStoppedListener = null; + _videoData.clear(); + } + + /// Pauses the current video recording. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future pauseVideoRecording() async { + if (mediaRecorder == null) { + throw _videoRecordingNotStartedException; + } + mediaRecorder!.pause(); + } + + /// Resumes the current video recording. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future resumeVideoRecording() async { + if (mediaRecorder == null) { + throw _videoRecordingNotStartedException; + } + mediaRecorder!.resume(); + } + + /// Stops the video recording and returns the captured video file. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future stopVideoRecording() async { + if (mediaRecorder == null || _videoAvailableCompleter == null) { + throw _videoRecordingNotStartedException; + } + + mediaRecorder!.stop(); + + return _videoAvailableCompleter!.future; + } + + /// Disposes the camera by stopping the camera stream, + /// the video recording and reloading the camera source. Future dispose() async { - /// Stop the camera stream. + // Stop the camera stream. stop(); - /// Reset the [videoElement] to its initial state. + await videoRecorderController.close(); + mediaRecorder = null; + _videoDataAvailableListener = null; + + // Reset the [videoElement] to its initial state. videoElement ..srcObject = null ..load(); await _onEndedSubscription?.cancel(); _onEndedSubscription = null; + await onEndedController.close(); - await onEndedStreamController.close(); + await _onVideoRecordingErrorSubscription?.cancel(); + _onVideoRecordingErrorSubscription = null; + await videoRecordingErrorController.close(); + } + + /// Returns the first supported video mime type (amongst mp4 and webm) + /// to use when recording a video. + /// + /// Throws a [CameraWebException] if the browser does not support + /// any of the available video mime types. + String get _videoMimeType { + const types = [ + 'video/mp4', + 'video/webm', + ]; + + return types.firstWhere( + (type) => isVideoTypeSupported(type), + orElse: () => throw CameraWebException( + textureId, + CameraErrorCode.notSupported, + 'The browser does not support any of the following video types: ${types.join(',')}.', + ), + ); } + CameraWebException get _videoRecordingNotStartedException => + CameraWebException( + textureId, + CameraErrorCode.videoRecordingNotStarted, + 'The video recorder is uninitialized. The recording might not have been started. Make sure to call `startVideoRecording` first.', + ); + /// Applies default styles to the video [element]. void _applyDefaultVideoStyles(html.VideoElement element) { final isBackCamera = getLensDirection() == CameraLensDirection.back; diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 92c43c45b6b9..0021ee47cbde 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -64,6 +64,9 @@ class CameraPlugin extends CameraPlatform { final _cameraEndedSubscriptions = >{}; + final _cameraVideoRecordingErrorSubscriptions = + >{}; + /// Returns a stream of camera events for the given [cameraId]. Stream _cameraEvents(int cameraId) => cameraEventStreamController.stream @@ -338,7 +341,7 @@ class CameraPlugin extends CameraPlatform { @override Stream onVideoRecordedEvent(int cameraId) { - throw UnimplementedError('onVideoRecordedEvent() is not implemented.'); + return getCamera(cameraId).onVideoRecordedEvent; } @override @@ -422,28 +425,73 @@ class CameraPlugin extends CameraPlatform { } @override - Future prepareForVideoRecording() { - throw UnimplementedError('prepareForVideoRecording() is not implemented.'); + Future prepareForVideoRecording() async { + // This is a no-op as it is not required for the web. } @override Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) { - throw UnimplementedError('startVideoRecording() is not implemented.'); + try { + final camera = getCamera(cameraId); + + // Add camera's video recording errors to the camera events stream. + // The error event fires when the video recording is not allowed or an unsupported + // codec is used. + _cameraVideoRecordingErrorSubscriptions[cameraId] = + camera.onVideoRecordingError.listen((html.ErrorEvent errorEvent) { + cameraEventStreamController.add( + CameraErrorEvent( + cameraId, + 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', + ), + ); + }); + + return camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override - Future stopVideoRecording(int cameraId) { - throw UnimplementedError('stopVideoRecording() is not implemented.'); + Future stopVideoRecording(int cameraId) async { + try { + final videoRecording = await getCamera(cameraId).stopVideoRecording(); + await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); + return videoRecording; + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override Future pauseVideoRecording(int cameraId) { - throw UnimplementedError('pauseVideoRecording() is not implemented.'); + try { + return getCamera(cameraId).pauseVideoRecording(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override Future resumeVideoRecording(int cameraId) { - throw UnimplementedError('resumeVideoRecording() is not implemented.'); + try { + return getCamera(cameraId).resumeVideoRecording(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override @@ -571,6 +619,7 @@ class CameraPlugin extends CameraPlatform { await _cameraVideoErrorSubscriptions[cameraId]?.cancel(); await _cameraVideoAbortSubscriptions[cameraId]?.cancel(); await _cameraEndedSubscriptions[cameraId]?.cancel(); + await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); cameras.remove(cameraId); _cameraVideoErrorSubscriptions.remove(cameraId); diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart index 210fa2baa9d2..f70925b4bede 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_code.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -68,6 +68,10 @@ class CameraErrorCode { static const CameraErrorCode notStarted = CameraErrorCode._('cameraNotStarted'); + /// The video recording was not started. + static const CameraErrorCode videoRecordingNotStarted = + CameraErrorCode._('videoRecordingNotStarted'); + /// An unknown camera error. static const CameraErrorCode unknown = CameraErrorCode._('cameraUnknown'); diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index fdfe3e38bb98..f001fe92365b 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.2.0 +version: 0.2.1 environment: sdk: ">=2.12.0 <3.0.0" From b8daefb6f36485189dd8a7a3b8f78cd56368380b Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Thu, 16 Sep 2021 23:12:13 +0200 Subject: [PATCH 290/364] [google_sign_in_web] Update URL to `google_sign_in` package in README (#4346) --- packages/google_sign_in/google_sign_in_web/CHANGELOG.md | 4 ++++ packages/google_sign_in/google_sign_in_web/README.md | 2 +- packages/google_sign_in/google_sign_in_web/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md index 7b9eb6b747ec..556f69524026 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.0+3 + +* Updated URL to the `google_sign_in` package in README. + ## 0.10.0+2 * Add `implements` to pubspec. diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md index 501ea14eebe6..4ee1a2956b45 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -1,6 +1,6 @@ # google\_sign\_in\_web -The web implementation of [google_sign_in](https://pub.dev/google_sign_in/google_sign_in) +The web implementation of [google_sign_in](https://pub.dev/packages/google_sign_in) ## Usage diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index 7075f43151a6..723dbe9ce56f 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.10.0+2 +version: 0.10.0+3 environment: sdk: ">=2.12.0 <3.0.0" From 3d32b3667a805329b67931062abad8ef4b9b65a7 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 16 Sep 2021 18:27:05 -0700 Subject: [PATCH 291/364] [ios_platform_images] Bump minimum Flutter version and iOS deployment target (#4353) --- packages/ios_platform_images/CHANGELOG.md | 4 ++++ .../example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- packages/ios_platform_images/example/pubspec.yaml | 3 ++- .../ios_platform_images/ios/ios_platform_images.podspec | 4 ++-- packages/ios_platform_images/pubspec.yaml | 6 +++--- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/ios_platform_images/CHANGELOG.md b/packages/ios_platform_images/CHANGELOG.md index a7270eed0576..2ebd1d1d2d7c 100644 --- a/packages/ios_platform_images/CHANGELOG.md +++ b/packages/ios_platform_images/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.0+2 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 0.2.0+1 * Add iOS unit test target. diff --git a/packages/ios_platform_images/example/ios/Flutter/AppFrameworkInfo.plist b/packages/ios_platform_images/example/ios/Flutter/AppFrameworkInfo.plist index 6b4c0f78a785..f2872cf474ee 100644 --- a/packages/ios_platform_images/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/ios_platform_images/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj index 0ee14af546ef..02e41bc13711 100644 --- a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj @@ -457,7 +457,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -539,7 +539,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -588,7 +588,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/packages/ios_platform_images/example/pubspec.yaml b/packages/ios_platform_images/example/pubspec.yaml index e45e7694cc2b..97241b677295 100644 --- a/packages/ios_platform_images/example/pubspec.yaml +++ b/packages/ios_platform_images/example/pubspec.yaml @@ -3,7 +3,8 @@ description: Demonstrates how to use the ios_platform_images plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/ios_platform_images/ios/ios_platform_images.podspec b/packages/ios_platform_images/ios/ios_platform_images.podspec index 485a0e52ffb3..ccbb9f9bda8a 100644 --- a/packages/ios_platform_images/ios/ios_platform_images.podspec +++ b/packages/ios_platform_images/ios/ios_platform_images.podspec @@ -17,9 +17,9 @@ Downloaded by pub (not CocoaPods). s.documentation_url = 'https://pub.dev/packages/ios_platform_images' s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.platform = :ios, '8.0' + s.platform = :ios, '9.0' # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' end diff --git a/packages/ios_platform_images/pubspec.yaml b/packages/ios_platform_images/pubspec.yaml index c3938856e386..adc8dc08011e 100644 --- a/packages/ios_platform_images/pubspec.yaml +++ b/packages/ios_platform_images/pubspec.yaml @@ -2,11 +2,11 @@ name: ios_platform_images description: A plugin to share images between Flutter and iOS in add-to-app setups. repository: https://github.com/flutter/plugins/tree/master/packages/ios_platform_images issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+ios_platform_images%22 -version: 0.2.0+1 +version: 0.2.0+2 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 701fdb6eab07c99d83fe583f1b7630100fe01a0e Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 16 Sep 2021 19:37:05 -0700 Subject: [PATCH 292/364] [image_picker] Bump minimum Flutter version and iOS deployment target (#4335) --- packages/image_picker/image_picker/CHANGELOG.md | 4 ++++ packages/image_picker/image_picker/README.md | 4 +++- .../image_picker/example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- packages/image_picker/image_picker/example/pubspec.yaml | 4 ++-- packages/image_picker/image_picker/ios/image_picker.podspec | 2 +- packages/image_picker/image_picker/pubspec.yaml | 6 +++--- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index de815b1ff302..4c89be1c3e48 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.4+2 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 0.8.4+1 * Fix README Example for `ImagePickerCache` to cache multiple files. diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index fc4813d1cee5..d8f5835fd402 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -11,8 +11,10 @@ First, add `image_picker` as a [dependency in your pubspec.yaml file](https://fl ### iOS +This plugin requires iOS 9.0 or higher. + Starting with version **0.8.1** the iOS implementation uses PHPicker to pick (multiple) images on iOS 14 or higher. -As a result of implementing PHPicker it becomes impossible to pick HEIC images on the iOS simulator in iOS 14+. This is a known issue. Please test this on a real device, or test with non-HEIC images until Apple solves this issue.[63426347 - Apple known issue](https://www.google.com/search?q=63426347+apple&sxsrf=ALeKk01YnTMid5S0PYvhL8GbgXJ40ZS[…]t=gws-wiz&ved=0ahUKEwjKh8XH_5HwAhWL_rsIHUmHDN8Q4dUDCA8&uact=5) +As a result of implementing PHPicker it becomes impossible to pick HEIC images on the iOS simulator in iOS 14+. This is a known issue. Please test this on a real device, or test with non-HEIC images until Apple solves this issue. [63426347 - Apple known issue](https://www.google.com/search?q=63426347+apple&sxsrf=ALeKk01YnTMid5S0PYvhL8GbgXJ40ZS[…]t=gws-wiz&ved=0ahUKEwjKh8XH_5HwAhWL_rsIHUmHDN8Q4dUDCA8&uact=5) Add the following keys to your _Info.plist_ file, located in `/ios/Runner/Info.plist`: diff --git a/packages/image_picker/image_picker/example/ios/Flutter/AppFrameworkInfo.plist b/packages/image_picker/image_picker/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100755 --- a/packages/image_picker/image_picker/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/image_picker/image_picker/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj index 2f28c9ad2d6d..192962839b24 100644 --- a/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/image_picker/image_picker/example/ios/Runner.xcodeproj/project.pbxproj @@ -684,7 +684,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -734,7 +734,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/image_picker/image_picker/example/pubspec.yaml b/packages/image_picker/image_picker/example/pubspec.yaml index 44ae0fc22c06..e11da82d5da8 100755 --- a/packages/image_picker/image_picker/example/pubspec.yaml +++ b/packages/image_picker/image_picker/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the image_picker plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.10.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: video_player: ^2.1.4 diff --git a/packages/image_picker/image_picker/ios/image_picker.podspec b/packages/image_picker/image_picker/ios/image_picker.podspec index 0d33b79e61f0..0d6cb0304723 100644 --- a/packages/image_picker/image_picker/ios/image_picker.podspec +++ b/packages/image_picker/image_picker/ios/image_picker.podspec @@ -17,6 +17,6 @@ Downloaded by pub (not CocoaPods). s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' + s.platform = :ios, '9.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } end diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index fca821be2d5f..ba5ce6635ed6 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.4+1 +version: 0.8.4+2 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From d5e7763c0f5c6c31cc56c8053b77aeb7c3cd4859 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 11:02:06 -0700 Subject: [PATCH 293/364] [local_auth] Bump minimum Flutter version and iOS deployment target (#4354) --- packages/local_auth/CHANGELOG.md | 3 +- .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 4 +-- packages/local_auth/example/pubspec.yaml | 4 +-- .../ios/Classes/FLTLocalAuthPlugin.m | 28 ++++++++----------- packages/local_auth/ios/local_auth.podspec | 4 +-- packages/local_auth/pubspec.yaml | 6 ++-- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/local_auth/CHANGELOG.md b/packages/local_auth/CHANGELOG.md index c0d04fb5688a..f4129f77f5d4 100644 --- a/packages/local_auth/CHANGELOG.md +++ b/packages/local_auth/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 1.1.8 +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. * Updated Android lint settings. ## 1.1.7 diff --git a/packages/local_auth/example/ios/Flutter/AppFrameworkInfo.plist b/packages/local_auth/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/local_auth/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/local_auth/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj b/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj index debbb1d06aba..3de4b94f9d5c 100644 --- a/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/local_auth/example/ios/Runner.xcodeproj/project.pbxproj @@ -494,7 +494,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -544,7 +544,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/local_auth/example/pubspec.yaml b/packages/local_auth/example/pubspec.yaml index ff7a27203019..3aa8fd848057 100644 --- a/packages/local_auth/example/pubspec.yaml +++ b/packages/local_auth/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the local_auth plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.12.13+hotfix.5" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/local_auth/ios/Classes/FLTLocalAuthPlugin.m b/packages/local_auth/ios/Classes/FLTLocalAuthPlugin.m index a00c7eed2703..c2dc9db25fc8 100644 --- a/packages/local_auth/ios/Classes/FLTLocalAuthPlugin.m +++ b/packages/local_auth/ios/Classes/FLTLocalAuthPlugin.m @@ -148,23 +148,19 @@ - (void)authenticate:(NSDictionary *)arguments withFlutterResult:(FlutterResult) _lastResult = nil; context.localizedFallbackTitle = @""; - if (@available(iOS 9.0, *)) { - if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication error:&authError]) { - [context evaluatePolicy:kLAPolicyDeviceOwnerAuthentication - localizedReason:arguments[@"localizedReason"] - reply:^(BOOL success, NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self handleAuthReplyWithSuccess:success - error:error - flutterArguments:arguments - flutterResult:result]; - }); - }]; - } else { - [self handleErrors:authError flutterArguments:arguments withFlutterResult:result]; - } + if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication error:&authError]) { + [context evaluatePolicy:kLAPolicyDeviceOwnerAuthentication + localizedReason:arguments[@"localizedReason"] + reply:^(BOOL success, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self handleAuthReplyWithSuccess:success + error:error + flutterArguments:arguments + flutterResult:result]; + }); + }]; } else { - // Fallback on earlier versions + [self handleErrors:authError flutterArguments:arguments withFlutterResult:result]; } } diff --git a/packages/local_auth/ios/local_auth.podspec b/packages/local_auth/ios/local_auth.podspec index b411ddd36067..917c4bf2a0eb 100644 --- a/packages/local_auth/ios/local_auth.podspec +++ b/packages/local_auth/ios/local_auth.podspec @@ -17,7 +17,7 @@ Downloaded by pub (not CocoaPods). s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/local_auth/pubspec.yaml b/packages/local_auth/pubspec.yaml index 8a31b2f7d501..4f5ef26d9fb1 100644 --- a/packages/local_auth/pubspec.yaml +++ b/packages/local_auth/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for Android and iOS devices to allow local authentication via fingerprint, touch ID, face ID, passcode, pin, or pattern. repository: https://github.com/flutter/plugins/tree/master/packages/local_auth issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+local_auth%22 -version: 1.1.7 +version: 1.1.8 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 626bc794589b064b7df42d32c95a380a7a59c0ee Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 11:07:06 -0700 Subject: [PATCH 294/364] [path_provider] Bump minimum Flutter version and iOS deployment target (#4355) --- packages/path_provider/path_provider/CHANGELOG.md | 4 ++++ .../example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- packages/path_provider/path_provider/example/pubspec.yaml | 4 ++-- .../path_provider/path_provider/ios/path_provider.podspec | 4 ++-- packages/path_provider/path_provider/pubspec.yaml | 6 +++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/path_provider/path_provider/CHANGELOG.md b/packages/path_provider/path_provider/CHANGELOG.md index f3f3b2e4da12..764662d5d84b 100644 --- a/packages/path_provider/path_provider/CHANGELOG.md +++ b/packages/path_provider/path_provider/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.5 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 2.0.4 * Updated Android lint settings. diff --git a/packages/path_provider/path_provider/example/ios/Flutter/AppFrameworkInfo.plist b/packages/path_provider/path_provider/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/path_provider/path_provider/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/path_provider/path_provider/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj index 09c902f41869..86528407809b 100644 --- a/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/path_provider/path_provider/example/ios/Runner.xcodeproj/project.pbxproj @@ -443,7 +443,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -493,7 +493,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/path_provider/path_provider/example/pubspec.yaml b/packages/path_provider/path_provider/example/pubspec.yaml index 61f50ae97262..0001fe580e78 100644 --- a/packages/path_provider/path_provider/example/pubspec.yaml +++ b/packages/path_provider/path_provider/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the path_provider plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.12.13+hotfix.5" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/path_provider/path_provider/ios/path_provider.podspec b/packages/path_provider/path_provider/ios/path_provider.podspec index fcadef593d36..86f27c6c8fa5 100644 --- a/packages/path_provider/path_provider/ios/path_provider.podspec +++ b/packages/path_provider/path_provider/ios/path_provider.podspec @@ -17,7 +17,7 @@ Downloaded by pub (not CocoaPods). s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/path_provider/path_provider/pubspec.yaml b/packages/path_provider/path_provider/pubspec.yaml index febfef516f85..5e9bc0b0e7c4 100644 --- a/packages/path_provider/path_provider/pubspec.yaml +++ b/packages/path_provider/path_provider/pubspec.yaml @@ -2,11 +2,11 @@ name: path_provider description: Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. repository: https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+path_provider%22 -version: 2.0.4 +version: 2.0.5 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From e1c011f1cef7045ed030812b2a92337c21dfd46c Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 11:12:06 -0700 Subject: [PATCH 295/364] [quick_actions] Bump minimum Flutter version and iOS deployment target (#4356) --- .../quick_actions/quick_actions/CHANGELOG.md | 4 ++ .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 37 +++++++++++++- .../ios/Classes/FLTQuickActionsPlugin.m | 51 ++++++++----------- .../quick_actions/ios/quick_actions.podspec | 4 +- .../quick_actions/quick_actions/pubspec.yaml | 6 +-- 6 files changed, 67 insertions(+), 37 deletions(-) diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index d893b67b10dc..d2d628cad428 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0+7 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 0.6.0+6 * Updated Android lint settings. diff --git a/packages/quick_actions/quick_actions/example/ios/Flutter/AppFrameworkInfo.plist b/packages/quick_actions/quick_actions/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/quick_actions/quick_actions/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/quick_actions/quick_actions/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj index 745315bcc3d2..d6cb74d0658b 100644 --- a/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/quick_actions/quick_actions/example/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 33E20B3526EFCDFC00A4A191 /* RunnerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33E20B3426EFCDFC00A4A191 /* RunnerTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 50EB54C1FE43DB743F5DEC7C /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D1A69703A518C37D73BF8B91 /* libPods-RunnerTests.a */; }; 686BE83025E58CCF00862533 /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686BE82F25E58CCF00862533 /* RunnerUITests.m */; }; 83C36CAF23D629E5ABE75B2A /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CCC799F2B0AB50A9C34344F0 /* libPods-Runner.a */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; @@ -63,6 +64,7 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 96F949A6B78E2DC62B93C4F8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -71,7 +73,9 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D27FE1F0F21D4D47DDA16DE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; CCC799F2B0AB50A9C34344F0 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + D1A69703A518C37D73BF8B91 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; F0609304FBCAEC2289164BD5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -80,6 +84,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 50EB54C1FE43DB743F5DEC7C /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -181,6 +186,7 @@ isa = PBXGroup; children = ( CCC799F2B0AB50A9C34344F0 /* libPods-Runner.a */, + D1A69703A518C37D73BF8B91 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -190,6 +196,8 @@ children = ( 5278439583922091276A37C9 /* Pods-Runner.debug.xcconfig */, F0609304FBCAEC2289164BD5 /* Pods-Runner.release.xcconfig */, + 9D27FE1F0F21D4D47DDA16DE /* Pods-RunnerTests.debug.xcconfig */, + 96F949A6B78E2DC62B93C4F8 /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -201,6 +209,7 @@ isa = PBXNativeTarget; buildConfigurationList = 33E20B3B26EFCDFC00A4A191 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 3B2E8279C112D7129C8D23F1 /* [CP] Check Pods Manifest.lock */, 33E20B2E26EFCDFC00A4A191 /* Sources */, 33E20B2F26EFCDFC00A4A191 /* Frameworks */, 33E20B3026EFCDFC00A4A191 /* Resources */, @@ -340,6 +349,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 3B2E8279C112D7129C8D23F1 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -438,6 +469,7 @@ /* Begin XCBuildConfiguration section */ 33E20B3926EFCDFC00A4A191 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9D27FE1F0F21D4D47DDA16DE /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; INFOPLIST_FILE = RunnerTests/Info.plist; @@ -450,6 +482,7 @@ }; 33E20B3A26EFCDFC00A4A191 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 96F949A6B78E2DC62B93C4F8 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; INFOPLIST_FILE = RunnerTests/Info.plist; @@ -553,7 +586,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -603,7 +636,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/quick_actions/quick_actions/ios/Classes/FLTQuickActionsPlugin.m b/packages/quick_actions/quick_actions/ios/Classes/FLTQuickActionsPlugin.m index 3a966f86a824..a099b696387c 100644 --- a/packages/quick_actions/quick_actions/ios/Classes/FLTQuickActionsPlugin.m +++ b/packages/quick_actions/quick_actions/ios/Classes/FLTQuickActionsPlugin.m @@ -24,21 +24,16 @@ + (void)registerWithRegistrar:(NSObject *)registrar { } - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { - if (@available(iOS 9.0, *)) { - if ([call.method isEqualToString:@"setShortcutItems"]) { - _setShortcutItems(call.arguments); - result(nil); - } else if ([call.method isEqualToString:@"clearShortcutItems"]) { - [UIApplication sharedApplication].shortcutItems = @[]; - result(nil); - } else if ([call.method isEqualToString:@"getLaunchAction"]) { - result(nil); - } else { - result(FlutterMethodNotImplemented); - } - } else { - NSLog(@"Shortcuts are not supported prior to iOS 9."); + if ([call.method isEqualToString:@"setShortcutItems"]) { + _setShortcutItems(call.arguments); + result(nil); + } else if ([call.method isEqualToString:@"clearShortcutItems"]) { + [UIApplication sharedApplication].shortcutItems = @[]; result(nil); + } else if ([call.method isEqualToString:@"getLaunchAction"]) { + result(nil); + } else { + result(FlutterMethodNotImplemented); } } @@ -57,21 +52,19 @@ - (BOOL)application:(UIApplication *)application - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - if (@available(iOS 9.0, *)) { - UIApplicationShortcutItem *shortcutItem = - launchOptions[UIApplicationLaunchOptionsShortcutItemKey]; - if (shortcutItem) { - // Keep hold of the shortcut type and handle it in the - // `applicationDidBecomeActure:` method once the Dart MethodChannel - // is initialized. - self.shortcutType = shortcutItem.type; - - // Return NO to indicate we handled the quick action to ensure - // the `application:performActionFor:` method is not called (as - // per Apple's documentation: - // https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622935-application?language=objc). - return NO; - } + UIApplicationShortcutItem *shortcutItem = + launchOptions[UIApplicationLaunchOptionsShortcutItemKey]; + if (shortcutItem) { + // Keep hold of the shortcut type and handle it in the + // `applicationDidBecomeActure:` method once the Dart MethodChannel + // is initialized. + self.shortcutType = shortcutItem.type; + + // Return NO to indicate we handled the quick action to ensure + // the `application:performActionFor:` method is not called (as + // per Apple's documentation: + // https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622935-application?language=objc). + return NO; } return YES; } diff --git a/packages/quick_actions/quick_actions/ios/quick_actions.podspec b/packages/quick_actions/quick_actions/ios/quick_actions.podspec index b7d56bf3d818..9452fd8c983d 100644 --- a/packages/quick_actions/quick_actions/ios/quick_actions.podspec +++ b/packages/quick_actions/quick_actions/ios/quick_actions.podspec @@ -17,6 +17,6 @@ Downloaded by pub (not CocoaPods). s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/quick_actions/quick_actions/pubspec.yaml b/packages/quick_actions/quick_actions/pubspec.yaml index c5d3fe4d4cbe..9531b7027cdf 100644 --- a/packages/quick_actions/quick_actions/pubspec.yaml +++ b/packages/quick_actions/quick_actions/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for creating shortcuts on home screen, also known as Quick Actions on iOS and App Shortcuts on Android. repository: https://github.com/flutter/plugins/tree/master/packages/quick_actions/quick_actions issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+quick_actions%22 -version: 0.6.0+6 +version: 0.6.0+7 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From b312402646853b4a5b0b923a613eb9731733116e Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 11:57:05 -0700 Subject: [PATCH 296/364] [google_maps_flutter] Bump minimum Flutter version and iOS deployment target (#4333) --- .../google_maps_flutter/google_maps_flutter/CHANGELOG.md | 4 ++++ packages/google_maps_flutter/google_maps_flutter/README.md | 2 +- .../google_maps_flutter/example/pubspec.yaml | 4 ++-- .../google_maps_flutter/ios/google_maps_flutter.podspec | 2 +- .../google_maps_flutter/google_maps_flutter/pubspec.yaml | 6 +++--- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 709aa3b90c19..5406dc50e04a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.10 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 2.0.9 * Fix Android `NullPointerException` caused by the `GoogleMapController` being disposed before `GoogleMap` was ready. diff --git a/packages/google_maps_flutter/google_maps_flutter/README.md b/packages/google_maps_flutter/google_maps_flutter/README.md index c80fcb949dad..99c04f3ae1df 100644 --- a/packages/google_maps_flutter/google_maps_flutter/README.md +++ b/packages/google_maps_flutter/google_maps_flutter/README.md @@ -48,7 +48,7 @@ This means that app will only be available for users that run Android SDK 20 or ### iOS -Specify your API key in the application delegate `ios/Runner/AppDelegate.m`: +This plugin requires iOS 9.0 or higher. To set up, specify your API key in the application delegate `ios/Runner/AppDelegate.m`: ```objectivec #include "AppDelegate.h" diff --git a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml index d15f76352b69..cd614c7e4384 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the google_maps_flutter plugin. publish_to: none environment: - sdk: '>=2.12.0 <3.0.0' - flutter: ">=1.22.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec b/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec index 292dda006fa4..35e4f3faf871 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec +++ b/packages/google_maps_flutter/google_maps_flutter/ios/google_maps_flutter.podspec @@ -19,7 +19,7 @@ Downloaded by pub (not CocoaPods). s.dependency 'Flutter' s.dependency 'GoogleMaps' s.static_framework = true - s.platform = :ios, '8.0' + s.platform = :ios, '9.0' # GoogleMaps does not support arm64 simulators. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' } end diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 9929eb935df1..641e475a56f0 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,11 +2,11 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.0.9 +version: 2.0.10 environment: - sdk: '>=2.12.0 <3.0.0' - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From f9ec026e24872c70d3fdf57021ba0eb9aa247601 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 15:08:06 -0700 Subject: [PATCH 297/364] [in_app_purchase] Bump minimum Flutter version and iOS deployment target (#4352) --- .../in_app_purchase/in_app_purchase_ios/CHANGELOG.md | 4 ++++ packages/in_app_purchase/in_app_purchase_ios/README.md | 2 +- .../example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../example/ios/RunnerTests/InAppPurchasePluginTests.m | 6 +----- .../in_app_purchase_ios/example/pubspec.yaml | 4 ++-- .../ios/Classes/FIAObjectTranslator.m | 8 ++------ .../ios/Classes/InAppPurchasePlugin.m | 10 ++++------ .../ios/in_app_purchase_ios.podspec | 4 ++-- .../in_app_purchase/in_app_purchase_ios/pubspec.yaml | 6 +++--- 10 files changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index e66b5dee6295..04509e56ecde 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.3+4 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 0.1.3+3 * Add `implements` to pubspec. diff --git a/packages/in_app_purchase/in_app_purchase_ios/README.md b/packages/in_app_purchase/in_app_purchase_ios/README.md index fcd4834e9cdc..7ac21c495f7b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/README.md +++ b/packages/in_app_purchase/in_app_purchase_ios/README.md @@ -24,6 +24,6 @@ If you would like to contribute to the plugin, check out our [contribution guide](https://github.com/flutter/plugins/blob/master/CONTRIBUTING.md). -[1]: ../in_app_purchase/in_app_purchase +[1]: ../in_app_purchase [2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin [3]: https://pub.dev/packages/in_app_purchase_ios/install diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Flutter/AppFrameworkInfo.plist b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Flutter/AppFrameworkInfo.plist index 9367d483e44e..8d4492f977ad 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj index 61a5da696986..a88050193053 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -539,7 +539,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m index 79812f609980..b51f622e939b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/InAppPurchasePluginTests.m @@ -169,11 +169,7 @@ - (void)testAddPaymentWithNullSandboxArgument { transactionForUpdateBlock = transaction; [expectation fulfill]; } - if (@available(iOS 8.3, *)) { - if (!transaction.payment.simulatesAskToBuyInSandbox) { - [simulatesAskToBuyInSandboxExpectation fulfill]; - } - } else { + if (!transaction.payment.simulatesAskToBuyInSandbox) { [simulatesAskToBuyInSandboxExpectation fulfill]; } } diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/example/pubspec.yaml index b916990d3979..0474d70e8b71 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the in_app_purchase_ios plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.9.1+hotfix.2" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m index 765ef4dd88d9..0125604b3b3c 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAObjectTranslator.m @@ -96,9 +96,7 @@ + (NSDictionary *)getMapFromSKPayment:(SKPayment *)payment { @"quantity" : @(payment.quantity), @"applicationUsername" : payment.applicationUsername ?: [NSNull null] }]; - if (@available(iOS 8.3, *)) { - [map setObject:@(payment.simulatesAskToBuyInSandbox) forKey:@"simulatesAskToBuyInSandbox"]; - } + [map setObject:@(payment.simulatesAskToBuyInSandbox) forKey:@"simulatesAskToBuyInSandbox"]; return map; } @@ -125,9 +123,7 @@ + (SKMutablePayment *)getSKMutablePaymentFromMap:(NSDictionary *)map { payment.requestData = [utf8String dataUsingEncoding:NSUTF8StringEncoding]; payment.quantity = [map[@"quantity"] integerValue]; payment.applicationUsername = map[@"applicationUsername"]; - if (@available(iOS 8.3, *)) { - payment.simulatesAskToBuyInSandbox = [map[@"simulatesAskToBuyInSandbox"] boolValue]; - } + payment.simulatesAskToBuyInSandbox = [map[@"simulatesAskToBuyInSandbox"] boolValue]; return payment; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m index c0db38e5cfe0..7e2d2ca80675 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/InAppPurchasePlugin.m @@ -198,12 +198,10 @@ - (void)addPayment:(FlutterMethodCall *)call result:(FlutterResult)result { payment.applicationUsername = [paymentMap objectForKey:@"applicationUsername"]; NSNumber *quantity = [paymentMap objectForKey:@"quantity"]; payment.quantity = (quantity != nil) ? quantity.integerValue : 1; - if (@available(iOS 8.3, *)) { - NSNumber *simulatesAskToBuyInSandbox = [paymentMap objectForKey:@"simulatesAskToBuyInSandbox"]; - payment.simulatesAskToBuyInSandbox = (id)simulatesAskToBuyInSandbox == (id)[NSNull null] - ? NO - : [simulatesAskToBuyInSandbox boolValue]; - } + NSNumber *simulatesAskToBuyInSandbox = [paymentMap objectForKey:@"simulatesAskToBuyInSandbox"]; + payment.simulatesAskToBuyInSandbox = (id)simulatesAskToBuyInSandbox == (id)[NSNull null] + ? NO + : [simulatesAskToBuyInSandbox boolValue]; if (![self.paymentQueueHandler addPayment:payment]) { result([FlutterError diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec b/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec index 785235336e43..3d15b5c0d02c 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/in_app_purchase_ios.podspec @@ -19,6 +19,6 @@ Downloaded by pub (not CocoaPods). s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS' => 'armv7 arm64 x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 07eae3ccc702..9ba642e2e590 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,11 +2,11 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3+3 +version: 0.1.3+4 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From b073e6e4a0df2cf9029375597e844f3deb95d559 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 15:08:08 -0700 Subject: [PATCH 298/364] [video_player] Bump minimum Flutter version and iOS deployment target (#4360) --- packages/video_player/video_player/CHANGELOG.md | 4 ++++ packages/video_player/video_player/README.md | 2 +- .../video_player/example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- packages/video_player/video_player/ios/video_player.podspec | 4 ++-- packages/video_player/video_player/pubspec.yaml | 6 +++--- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 69ad92c13fe8..de60af49b95d 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.4 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 2.2.3 * Fixed empty caption text still showing the caption widget. diff --git a/packages/video_player/video_player/README.md b/packages/video_player/video_player/README.md index 4d2bf80a2628..d5e7528fa973 100644 --- a/packages/video_player/video_player/README.md +++ b/packages/video_player/video_player/README.md @@ -12,7 +12,7 @@ First, add `video_player` as a [dependency in your pubspec.yaml file](https://fl ### iOS -Add the following entry to your _Info.plist_ file, located in `/ios/Runner/Info.plist`: +This plugin requires iOS 9.0 or higher. Add the following entry to your _Info.plist_ file, located in `/ios/Runner/Info.plist`: ```xml NSAppTransportSecurity diff --git a/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist b/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj b/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj index 7cef313627f0..2921ef9161be 100644 --- a/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj @@ -516,7 +516,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -566,7 +566,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/video_player/video_player/ios/video_player.podspec b/packages/video_player/video_player/ios/video_player.podspec index bd21f4c15365..86230f65db7c 100644 --- a/packages/video_player/video_player/ios/video_player.podspec +++ b/packages/video_player/video_player/ios/video_player.podspec @@ -18,7 +18,7 @@ Downloaded by pub (not CocoaPods). s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 317d46d851b7..926add50f43c 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.2.3 +version: 2.2.4 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 76d60987a14b0d1811ccf8b69d2ef4a4761ed587 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 15:13:07 -0700 Subject: [PATCH 299/364] [shared_preferences] Bump minimum Flutter version and iOS deployment target (#4357) --- packages/shared_preferences/shared_preferences/CHANGELOG.md | 4 ++++ .../example/ios/Flutter/AppFrameworkInfo.plist | 2 +- .../example/ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../shared_preferences/example/pubspec.yaml | 4 ++-- .../shared_preferences/ios/shared_preferences.podspec | 4 ++-- packages/shared_preferences/shared_preferences/pubspec.yaml | 6 +++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index 57b35a81255b..db12fd1829aa 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.8 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 2.0.7 * Add iOS unit test target. diff --git a/packages/shared_preferences/shared_preferences/example/ios/Flutter/AppFrameworkInfo.plist b/packages/shared_preferences/shared_preferences/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/shared_preferences/shared_preferences/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/shared_preferences/shared_preferences/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj index 395e3009aa37..c7567b312596 100644 --- a/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/shared_preferences/shared_preferences/example/ios/Runner.xcodeproj/project.pbxproj @@ -443,7 +443,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -493,7 +493,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/shared_preferences/shared_preferences/example/pubspec.yaml b/packages/shared_preferences/shared_preferences/example/pubspec.yaml index 8f14d5c1ec5b..1cb0f185baf4 100644 --- a/packages/shared_preferences/shared_preferences/example/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the shared_preferences plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.9.1+hotfix.2" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/shared_preferences/shared_preferences/ios/shared_preferences.podspec b/packages/shared_preferences/shared_preferences/ios/shared_preferences.podspec index bd239ec5a632..0cb5d35e1dd0 100644 --- a/packages/shared_preferences/shared_preferences/ios/shared_preferences.podspec +++ b/packages/shared_preferences/shared_preferences/ios/shared_preferences.podspec @@ -17,7 +17,7 @@ Wraps NSUserDefaults, providing a persistent store for simple key-value pairs. s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/shared_preferences/shared_preferences/pubspec.yaml b/packages/shared_preferences/shared_preferences/pubspec.yaml index e3cdfe4f87b3..1e59edf1e12e 100644 --- a/packages/shared_preferences/shared_preferences/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. repository: https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+shared_preferences%22 -version: 2.0.7 +version: 2.0.8 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 1e92448e8c24fca5685ddfa37acc2535bf481983 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Fri, 17 Sep 2021 15:18:07 -0700 Subject: [PATCH 300/364] [camera_platform_interface] Add web-relevant docs (#4358) --- .../camera/camera_platform_interface/CHANGELOG.md | 4 ++++ .../lib/src/platform_interface/camera_platform.dart | 13 ++++++++----- .../lib/src/types/resolution_preset.dart | 6 +++--- .../camera/camera_platform_interface/pubspec.yaml | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/camera/camera_platform_interface/CHANGELOG.md b/packages/camera/camera_platform_interface/CHANGELOG.md index 6567d00aa852..195e142fe10f 100644 --- a/packages/camera/camera_platform_interface/CHANGELOG.md +++ b/packages/camera/camera_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.1 + +* Add web-relevant docs to platform interface code. + ## 2.1.0 * Introduces interface methods for pausing and resuming the camera preview. diff --git a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart index 7a7bbf3da592..aafeef890f1b 100644 --- a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart +++ b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart @@ -64,6 +64,7 @@ abstract class CameraPlatform extends PlatformInterface { /// [imageFormatGroup] is used to specify the image formatting used. /// On Android this defaults to ImageFormat.YUV_420_888 and applies only to the imageStream. /// On iOS this defaults to kCVPixelFormatType_32BGRA. + /// On Web this parameter is currently not supported. Future initializeCamera( int cameraId, { ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, @@ -71,12 +72,13 @@ abstract class CameraPlatform extends PlatformInterface { throw UnimplementedError('initializeCamera() is not implemented.'); } - /// The camera has been initialized + /// The camera has been initialized. Stream onCameraInitialized(int cameraId) { throw UnimplementedError('onCameraInitialized() is not implemented.'); } - /// The camera's resolution has changed + /// The camera's resolution has changed. + /// On Web this returns an empty stream. Stream onCameraResolutionChanged(int cameraId) { throw UnimplementedError('onResolutionChanged() is not implemented.'); } @@ -91,7 +93,7 @@ abstract class CameraPlatform extends PlatformInterface { throw UnimplementedError('onCameraError() is not implemented.'); } - /// The camera finished recording a video + /// The camera finished recording a video. Stream onVideoRecordedEvent(int cameraId) { throw UnimplementedError('onCameraTimeLimitReached() is not implemented.'); } @@ -153,6 +155,7 @@ abstract class CameraPlatform extends PlatformInterface { } /// Sets the flash mode for the selected camera. + /// On Web [FlashMode.auto] corresponds to [FlashMode.always]. Future setFlashMode(int cameraId, FlashMode mode) { throw UnimplementedError('setFlashMode() is not implemented.'); } @@ -227,8 +230,8 @@ abstract class CameraPlatform extends PlatformInterface { /// Set the zoom level for the selected camera. /// - /// The supplied [zoom] value should be between 1.0 and the maximum supported - /// zoom level returned by the `getMaxZoomLevel`. Throws a `CameraException` + /// The supplied [zoom] value should be between the minimum and the maximum supported + /// zoom level returned by `getMinZoomLevel` and `getMaxZoomLevel`. Throws a `CameraException` /// when an illegal zoom level is supplied. Future setZoomLevel(int cameraId, double zoom) { throw UnimplementedError('setZoomLevel() is not implemented.'); diff --git a/packages/camera/camera_platform_interface/lib/src/types/resolution_preset.dart b/packages/camera/camera_platform_interface/lib/src/types/resolution_preset.dart index 1724f6c97517..fcb6b83bbf14 100644 --- a/packages/camera/camera_platform_interface/lib/src/types/resolution_preset.dart +++ b/packages/camera/camera_platform_interface/lib/src/types/resolution_preset.dart @@ -6,10 +6,10 @@ /// /// If a preset is not available on the camera being used a preset of lower quality will be selected automatically. enum ResolutionPreset { - /// 352x288 on iOS, 240p (320x240) on Android + /// 352x288 on iOS, 240p (320x240) on Android and Web low, - /// 480p (640x480 on iOS, 720x480 on Android) + /// 480p (640x480 on iOS, 720x480 on Android and Web) medium, /// 720p (1280x720) @@ -18,7 +18,7 @@ enum ResolutionPreset { /// 1080p (1920x1080) veryHigh, - /// 2160p (3840x2160) + /// 2160p (3840x2160 on Android and iOS, 4096x2160 on Web) ultraHigh, /// The highest resolution available. diff --git a/packages/camera/camera_platform_interface/pubspec.yaml b/packages/camera/camera_platform_interface/pubspec.yaml index d691afd41c21..41c6a9705482 100644 --- a/packages/camera/camera_platform_interface/pubspec.yaml +++ b/packages/camera/camera_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/camera/camer issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.1.0 +version: 2.1.1 environment: sdk: '>=2.12.0 <3.0.0' From c940b709b74a423aa7385d0940e4aa4e76eb7b92 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 15:23:06 -0700 Subject: [PATCH 301/364] [url_launcher] Bump minimum Flutter version and iOS deployment target (#4359) --- .../url_launcher/url_launcher/CHANGELOG.md | 3 ++- .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 4 ++-- .../url_launcher/example/pubspec.yaml | 4 ++-- .../ios/Classes/FLTURLLauncherPlugin.m | 21 ++++--------------- .../url_launcher/ios/url_launcher.podspec | 4 ++-- .../url_launcher/lib/url_launcher.dart | 5 +---- .../url_launcher/url_launcher/pubspec.yaml | 6 +++--- 8 files changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 237f0b139475..4b52a8d46f8b 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 6.0.11 +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. * Updated Android lint settings. ## 6.0.10 diff --git a/packages/url_launcher/url_launcher/example/ios/Flutter/AppFrameworkInfo.plist b/packages/url_launcher/url_launcher/example/ios/Flutter/AppFrameworkInfo.plist index 6c2de8086bcd..3a9c234f96d4 100644 --- a/packages/url_launcher/url_launcher/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/url_launcher/url_launcher/example/ios/Flutter/AppFrameworkInfo.plist @@ -25,6 +25,6 @@ arm64 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj b/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj index ffc37abef072..595f85d9a75b 100644 --- a/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/url_launcher/url_launcher/example/ios/Runner.xcodeproj/project.pbxproj @@ -517,7 +517,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -567,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/url_launcher/url_launcher/example/pubspec.yaml b/packages/url_launcher/url_launcher/example/pubspec.yaml index 643c53f9a6cb..db1d548695dc 100644 --- a/packages/url_launcher/url_launcher/example/pubspec.yaml +++ b/packages/url_launcher/url_launcher/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Demonstrates how to use the url_launcher plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.12.13+hotfix.5" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/url_launcher/url_launcher/ios/Classes/FLTURLLauncherPlugin.m b/packages/url_launcher/url_launcher/ios/Classes/FLTURLLauncherPlugin.m index 6fbc3a522f36..9ba9b1331728 100644 --- a/packages/url_launcher/url_launcher/ios/Classes/FLTURLLauncherPlugin.m +++ b/packages/url_launcher/url_launcher/ios/Classes/FLTURLLauncherPlugin.m @@ -23,10 +23,8 @@ - (instancetype)initWithUrl:url withFlutterResult:result { if (self) { self.url = url; self.flutterResult = result; - if (@available(iOS 9.0, *)) { - self.safari = [[SFSafariViewController alloc] initWithURL:url]; - self.safari.delegate = self; - } + self.safari = [[SFSafariViewController alloc] initWithURL:url]; + self.safari.delegate = self; } return self; } @@ -78,23 +76,12 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else if ([@"launch" isEqualToString:call.method]) { NSNumber *useSafariVC = call.arguments[@"useSafariVC"]; if (useSafariVC.boolValue) { - if (@available(iOS 9.0, *)) { - [self launchURLInVC:url result:result]; - } else { - [self launchURL:url call:call result:result]; - } + [self launchURLInVC:url result:result]; } else { [self launchURL:url call:call result:result]; } } else if ([@"closeWebView" isEqualToString:call.method]) { - if (@available(iOS 9.0, *)) { - [self closeWebViewWithResult:result]; - } else { - result([FlutterError - errorWithCode:@"API_NOT_AVAILABLE" - message:@"SafariViewController related api is not availabe for version <= IOS9" - details:nil]); - } + [self closeWebViewWithResult:result]; } else { result(FlutterMethodNotImplemented); } diff --git a/packages/url_launcher/url_launcher/ios/url_launcher.podspec b/packages/url_launcher/url_launcher/ios/url_launcher.podspec index c8bba7e02c3e..6af64b66bee4 100644 --- a/packages/url_launcher/url_launcher/ios/url_launcher.podspec +++ b/packages/url_launcher/url_launcher/ios/url_launcher.podspec @@ -17,7 +17,7 @@ A Flutter plugin for making the underlying platform (Android or iOS) launch a UR s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index 8c46520a71c4..239e3c46c480 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -16,7 +16,7 @@ import 'package:url_launcher_platform_interface/url_launcher_platform_interface. /// schemes which cannot be handled, that is when [canLaunch] would complete /// with false. /// -/// [forceSafariVC] is only used in iOS with iOS version >= 9.0. By default (when unset), the launcher +/// By default when [forceSafariVC] is unset, the launcher /// opens web URLs in the Safari View Controller, anything else is opened /// using the default handler on the platform. If set to true, it opens the /// URL in the Safari View Controller. If false, the URL is opened in the @@ -138,9 +138,6 @@ Future canLaunch(String urlString) async { /// Or on IOS systems, if [launch] was called without `forceSafariVC` being set to `true`, /// this call will not do anything either, simply because there is no /// WebView/SafariViewController available to be closed. -/// -/// SafariViewController is only available on IOS version >= 9.0, this method does not do anything -/// on IOS version below 9.0 Future closeWebView() async { return await UrlLauncherPlatform.instance.closeWebView(); } diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index cbd1be7bc3fd..c90d2feb08f4 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.10 +version: 6.0.11 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 17517465da20aa3acfe6132202d9e231b43ee157 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 15:28:05 -0700 Subject: [PATCH 302/364] [camera] Remove iOS 9 availability check around ultra high capture sessions (#4362) --- packages/camera/camera/CHANGELOG.md | 4 ++++ packages/camera/camera/ios/Classes/CameraPlugin.m | 10 ++++------ packages/camera/camera/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index ba2cb313b908..0e5385db44e0 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.3+1 + +* Remove iOS 9 availability check around ultra high capture sessions. + ## 0.9.3 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index cb93e9f5349d..da560d6c4df7 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -532,12 +532,10 @@ - (void)setCaptureSessionPreset:(ResolutionPreset)resolutionPreset { switch (resolutionPreset) { case max: case ultraHigh: - if (@available(iOS 9.0, *)) { - if ([_captureSession canSetSessionPreset:AVCaptureSessionPreset3840x2160]) { - _captureSession.sessionPreset = AVCaptureSessionPreset3840x2160; - _previewSize = CGSizeMake(3840, 2160); - break; - } + if ([_captureSession canSetSessionPreset:AVCaptureSessionPreset3840x2160]) { + _captureSession.sessionPreset = AVCaptureSessionPreset3840x2160; + _previewSize = CGSizeMake(3840, 2160); + break; } if ([_captureSession canSetSessionPreset:AVCaptureSessionPresetHigh]) { _captureSession.sessionPreset = AVCaptureSessionPresetHigh; diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 8d578bbdf065..7efc7930cd54 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.3 +version: 0.9.3+1 environment: sdk: ">=2.14.0 <3.0.0" From 1fe19f1d4946c2101a6ebbaed74682b4860009ce Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 17 Sep 2021 16:33:03 -0700 Subject: [PATCH 303/364] [webview_flutter] Bump minimum Flutter version and iOS deployment target (#4361) --- .../webview_flutter/CHANGELOG.md | 4 ++ .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 4 +- .../FLTWKNavigationDelegateTests.m | 19 ++++----- .../webview_flutter/example/pubspec.yaml | 3 +- .../ios/Classes/FLTCookieManager.m | 33 +++++++-------- .../ios/Classes/FlutterWebView.m | 42 ++++++------------- .../ios/webview_flutter.podspec | 2 +- .../webview_flutter/pubspec.yaml | 6 +-- 9 files changed, 48 insertions(+), 67 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 1e1d5aa523ba..99b8a5c419ca 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.14 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 2.0.13 * Send URL of File to download to the NavigationDelegate on Android just like it is already done on iOS. diff --git a/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist index 9367d483e44e..8d4492f977ad 100644 --- a/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 8.0 + 9.0 diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj index f75e71d1743a..62428d041adf 100644 --- a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -547,7 +547,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -597,7 +597,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m index 9d3a2aed64eb..08c2e8b60832 100644 --- a/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m +++ b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m @@ -25,17 +25,14 @@ - (void)setUp { } - (void)testWebViewWebContentProcessDidTerminateCallsRecourseErrorChannel { - if (@available(iOS 9.0, *)) { - // `webViewWebContentProcessDidTerminate` is only available on iOS 9.0 and above. - WKWebView *webview = OCMClassMock(WKWebView.class); - [self.navigationDelegate webViewWebContentProcessDidTerminate:webview]; - OCMVerify([self.mockMethodChannel - invokeMethod:@"onWebResourceError" - arguments:[OCMArg checkWithBlock:^BOOL(NSDictionary *args) { - XCTAssertEqualObjects(args[@"errorType"], @"webContentProcessTerminated"); - return true; - }]]); - } + WKWebView *webview = OCMClassMock(WKWebView.class); + [self.navigationDelegate webViewWebContentProcessDidTerminate:webview]; + OCMVerify([self.mockMethodChannel invokeMethod:@"onWebResourceError" + arguments:[OCMArg checkWithBlock:^BOOL(NSDictionary *args) { + XCTAssertEqualObjects(args[@"errorType"], + @"webContentProcessTerminated"); + return true; + }]]); } @end diff --git a/packages/webview_flutter/webview_flutter/example/pubspec.yaml b/packages/webview_flutter/webview_flutter/example/pubspec.yaml index 2316d7941427..6b668eb96af3 100644 --- a/packages/webview_flutter/webview_flutter/example/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/example/pubspec.yaml @@ -3,7 +3,8 @@ description: Demonstrates how to use the webview_flutter plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m index f4783ffb4123..eb7c856b250d 100644 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m +++ b/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m @@ -25,25 +25,20 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } - (void)clearCookies:(FlutterResult)result { - if (@available(iOS 9.0, *)) { - NSSet *websiteDataTypes = [NSSet setWithObject:WKWebsiteDataTypeCookies]; - WKWebsiteDataStore *dataStore = [WKWebsiteDataStore defaultDataStore]; - - void (^deleteAndNotify)(NSArray *) = - ^(NSArray *cookies) { - BOOL hasCookies = cookies.count > 0; - [dataStore removeDataOfTypes:websiteDataTypes - forDataRecords:cookies - completionHandler:^{ - result(@(hasCookies)); - }]; - }; - - [dataStore fetchDataRecordsOfTypes:websiteDataTypes completionHandler:deleteAndNotify]; - } else { - // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. - NSLog(@"Clearing cookies is not supported for Flutter WebViews prior to iOS 9."); - } + NSSet *websiteDataTypes = [NSSet setWithObject:WKWebsiteDataTypeCookies]; + WKWebsiteDataStore *dataStore = [WKWebsiteDataStore defaultDataStore]; + + void (^deleteAndNotify)(NSArray *) = + ^(NSArray *cookies) { + BOOL hasCookies = cookies.count > 0; + [dataStore removeDataOfTypes:websiteDataTypes + forDataRecords:cookies + completionHandler:^{ + result(@(hasCookies)); + }]; + }; + + [dataStore fetchDataRecordsOfTypes:websiteDataTypes completionHandler:deleteAndNotify]; } @end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m index c6d926d3cfc2..1604f2756f31 100644 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m +++ b/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m @@ -272,19 +272,14 @@ - (void)onRemoveJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResul } - (void)clearCache:(FlutterResult)result { - if (@available(iOS 9.0, *)) { - NSSet* cacheDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; - WKWebsiteDataStore* dataStore = [WKWebsiteDataStore defaultDataStore]; - NSDate* dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; - [dataStore removeDataOfTypes:cacheDataTypes - modifiedSince:dateFrom - completionHandler:^{ - result(nil); - }]; - } else { - // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. - NSLog(@"Clearing cache is not supported for Flutter WebViews prior to iOS 9."); - } + NSSet* cacheDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; + WKWebsiteDataStore* dataStore = [WKWebsiteDataStore defaultDataStore]; + NSDate* dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; + [dataStore removeDataOfTypes:cacheDataTypes + modifiedSince:dateFrom + completionHandler:^{ + result(nil); + }]; } - (void)onGetTitle:(FlutterResult)result { @@ -391,25 +386,18 @@ - (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy case 0: // require_user_action_for_all_media_types if (@available(iOS 10.0, *)) { configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; - } else if (@available(iOS 9.0, *)) { - configuration.requiresUserActionForMediaPlayback = true; } else { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - configuration.mediaPlaybackRequiresUserAction = true; -#pragma clang diagnostic pop + configuration.requiresUserActionForMediaPlayback = true; } break; case 1: // always_allow if (@available(iOS 10.0, *)) { - configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; - } else if (@available(iOS 9.0, *)) { - configuration.requiresUserActionForMediaPlayback = false; - } else { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - configuration.mediaPlaybackRequiresUserAction = false; + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; #pragma clang diagnostic pop + } else { + configuration.requiresUserActionForMediaPlayback = false; } break; default: @@ -468,11 +456,7 @@ - (void)registerJavaScriptChannels:(NSSet*)channelNames } - (void)updateUserAgent:(NSString*)userAgent { - if (@available(iOS 9.0, *)) { - [_webView setCustomUserAgent:userAgent]; - } else { - NSLog(@"Updating UserAgent is not supported for Flutter WebViews prior to iOS 9."); - } + [_webView setCustomUserAgent:userAgent]; } #pragma mark WKUIDelegate diff --git a/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec b/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec index 1602f1c43daf..2e021994b8f4 100644 --- a/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec +++ b/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec @@ -18,6 +18,6 @@ Downloaded by pub (not CocoaPods). s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - s.platform = :ios, '8.0' + s.platform = :ios, '9.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } end diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index 3976ff74fef6..393a66e3f92e 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,11 +2,11 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.13 +version: 2.0.14 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 42b990962578f1d7e7477bea9a4c774ad672b53a Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Mon, 20 Sep 2021 18:15:46 +0200 Subject: [PATCH 304/364] [camera] Add web support (#4240) * feat: add web to the example app * docs: update README and point users to camera_web for more web-specific info. --- packages/camera/camera/CHANGELOG.md | 4 + packages/camera/camera/README.md | 7 +- packages/camera/camera/example/lib/main.dart | 86 ++++++++++++------ .../camera/camera/example/web/favicon.png | Bin 0 -> 917 bytes .../camera/example/web/icons/Icon-192.png | Bin 0 -> 5292 bytes .../camera/example/web/icons/Icon-512.png | Bin 0 -> 8252 bytes packages/camera/camera/example/web/index.html | 39 ++++++++ .../camera/camera/example/web/manifest.json | 23 +++++ .../camera/camera/lib/src/camera_preview.dart | 2 +- packages/camera/camera/pubspec.yaml | 7 +- .../camera/test/camera_preview_test.dart | 2 +- 11 files changed, 135 insertions(+), 35 deletions(-) create mode 100644 packages/camera/camera/example/web/favicon.png create mode 100644 packages/camera/camera/example/web/icons/Icon-192.png create mode 100644 packages/camera/camera/example/web/icons/Icon-512.png create mode 100644 packages/camera/camera/example/web/index.html create mode 100644 packages/camera/camera/example/web/manifest.json diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 0e5385db44e0..62b5f1f9bd4c 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.4 + +* Add web support by endorsing `package:camera_web`. + ## 0.9.3+1 * Remove iOS 9 availability check around ultra high capture sessions. diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index aa34273fb92c..24566e76bbfc 100644 --- a/packages/camera/camera/README.md +++ b/packages/camera/camera/README.md @@ -2,7 +2,7 @@ [![pub package](https://img.shields.io/pub/v/camera.svg)](https://pub.dev/packages/camera) -A Flutter plugin for iOS and Android allowing access to the device cameras. +A Flutter plugin for iOS, Android and Web allowing access to the device cameras. *Note*: This plugin is still under development, and some APIs might not be available yet. We are working on a refactor which can be followed here: [issue](https://github.com/flutter/flutter/issues/31225) @@ -47,6 +47,11 @@ minSdkVersion 21 It's important to note that the `MediaRecorder` class is not working properly on emulators, as stated in the documentation: https://developer.android.com/reference/android/media/MediaRecorder. Specifically, when recording a video with sound enabled and trying to play it back, the duration won't be correct and you will only see the first frame. +### Web integration + +For web integration details, see the +[`camera_web` package](https://pub.dev/packages/camera_web). + ### Handling Lifecycle states As of version [0.5.0](https://github.com/flutter/plugins/blob/master/packages/camera/CHANGELOG.md#050) of the camera plugin, lifecycle changes are no longer handled by the plugin. This means developers are now responsible to control camera resources when the lifecycle state is updated. Failure to do so might lead to unexpected behavior (for example as described in issue [#39109](https://github.com/flutter/flutter/issues/39109)). Handling lifecycle changes can be done by overriding the `didChangeAppLifecycleState` method like so: diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index c0e90eefa3ab..a3a5d1d46391 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -8,6 +8,7 @@ import 'dart:async'; import 'dart:io'; import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -231,7 +232,14 @@ class _CameraExampleHomeState extends State ? Container() : SizedBox( child: (localVideoController == null) - ? Image.file(File(imageFile!.path)) + ? ( + // The captured image on the web contains a network-accessible URL + // pointing to a location within the browser. It may be displayed + // either with Image.network or Image.memory after loading the image + // bytes to memory. + kIsWeb + ? Image.network(imageFile!.path) + : Image.file(File(imageFile!.path))) : Container( child: Center( child: AspectRatio( @@ -267,17 +275,24 @@ class _CameraExampleHomeState extends State color: Colors.blue, onPressed: controller != null ? onFlashModeButtonPressed : null, ), - IconButton( - icon: Icon(Icons.exposure), - color: Colors.blue, - onPressed: - controller != null ? onExposureModeButtonPressed : null, - ), - IconButton( - icon: Icon(Icons.filter_center_focus), - color: Colors.blue, - onPressed: controller != null ? onFocusModeButtonPressed : null, - ), + // The exposure and focus mode are currently not supported on the web. + ...(!kIsWeb + ? [ + IconButton( + icon: Icon(Icons.exposure), + color: Colors.blue, + onPressed: controller != null + ? onExposureModeButtonPressed + : null, + ), + IconButton( + icon: Icon(Icons.filter_center_focus), + color: Colors.blue, + onPressed: + controller != null ? onFocusModeButtonPressed : null, + ) + ] + : []), IconButton( icon: Icon(enableAudio ? Icons.volume_up : Icons.volume_mute), color: Colors.blue, @@ -616,7 +631,7 @@ class _CameraExampleHomeState extends State final CameraController cameraController = CameraController( cameraDescription, - ResolutionPreset.medium, + kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, enableAudio: enableAudio, imageFormatGroup: ImageFormatGroup.jpeg, ); @@ -635,12 +650,17 @@ class _CameraExampleHomeState extends State try { await cameraController.initialize(); await Future.wait([ - cameraController - .getMinExposureOffset() - .then((value) => _minAvailableExposureOffset = value), - cameraController - .getMaxExposureOffset() - .then((value) => _maxAvailableExposureOffset = value), + // The exposure mode is currently not supported on the web. + ...(!kIsWeb + ? [ + cameraController + .getMinExposureOffset() + .then((value) => _minAvailableExposureOffset = value), + cameraController + .getMaxExposureOffset() + .then((value) => _maxAvailableExposureOffset = value) + ] + : []), cameraController .getMaxZoomLevel() .then((value) => _maxAvailableZoom = value), @@ -708,16 +728,20 @@ class _CameraExampleHomeState extends State } void onCaptureOrientationLockButtonPressed() async { - if (controller != null) { - final CameraController cameraController = controller!; - if (cameraController.value.isCaptureOrientationLocked) { - await cameraController.unlockCaptureOrientation(); - showInSnackBar('Capture orientation unlocked'); - } else { - await cameraController.lockCaptureOrientation(); - showInSnackBar( - 'Capture orientation locked to ${cameraController.value.lockedCaptureOrientation.toString().split('.').last}'); + try { + if (controller != null) { + final CameraController cameraController = controller!; + if (cameraController.value.isCaptureOrientationLocked) { + await cameraController.unlockCaptureOrientation(); + showInSnackBar('Capture orientation unlocked'); + } else { + await cameraController.lockCaptureOrientation(); + showInSnackBar( + 'Capture orientation locked to ${cameraController.value.lockedCaptureOrientation.toString().split('.').last}'); + } } + } on CameraException catch (e) { + _showCameraException(e); } } @@ -916,8 +940,10 @@ class _CameraExampleHomeState extends State return; } - final VideoPlayerController vController = - VideoPlayerController.file(File(videoFile!.path)); + final VideoPlayerController vController = kIsWeb + ? VideoPlayerController.network(videoFile!.path) + : VideoPlayerController.file(File(videoFile!.path)); + videoPlayerListener = () { if (videoController != null && videoController!.value.size != null) { // Refreshing the state to update video player with the correct ratio. diff --git a/packages/camera/camera/example/web/favicon.png b/packages/camera/camera/example/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/packages/camera/camera/example/web/icons/Icon-192.png b/packages/camera/camera/example/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/packages/camera/camera/example/web/icons/Icon-512.png b/packages/camera/camera/example/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/packages/camera/camera/example/web/index.html b/packages/camera/camera/example/web/index.html new file mode 100644 index 000000000000..2a3117d29362 --- /dev/null +++ b/packages/camera/camera/example/web/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + Camera Web Example + + + + + + + + + + \ No newline at end of file diff --git a/packages/camera/camera/example/web/manifest.json b/packages/camera/camera/example/web/manifest.json new file mode 100644 index 000000000000..5fe0e048afe6 --- /dev/null +++ b/packages/camera/camera/example/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "camera example", + "short_name": "camera", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "An example of the camera on the web.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/packages/camera/camera/lib/src/camera_preview.dart b/packages/camera/camera/lib/src/camera_preview.dart index 6a15896bfa47..5faa69f3cb9d 100644 --- a/packages/camera/camera/lib/src/camera_preview.dart +++ b/packages/camera/camera/lib/src/camera_preview.dart @@ -43,7 +43,7 @@ class CameraPreview extends StatelessWidget { } Widget _wrapInRotatedBox({required Widget child}) { - if (defaultTargetPlatform != TargetPlatform.android) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { return child; } diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 7efc7930cd54..b8894d58ac3a 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -1,10 +1,10 @@ name: camera description: A Flutter plugin for getting information about and controlling the - camera on Android and iOS. Supports previewing the camera feed, capturing images, capturing video, + camera on Android, iOS and Web. Supports previewing the camera feed, capturing images, capturing video, and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.3+1 +version: 0.9.4 environment: sdk: ">=2.14.0 <3.0.0" @@ -18,9 +18,12 @@ flutter: pluginClass: CameraPlugin ios: pluginClass: CameraPlugin + web: + default_package: camera_web dependencies: camera_platform_interface: ^2.1.0 + camera_web: ^0.2.1 flutter: sdk: flutter pedantic: ^1.10.0 diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index 14afddaea070..32718f4d5169 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -221,7 +221,7 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - }); + }, skip: kIsWeb); testWidgets('when not on Android there should not be a rotated box', (WidgetTester tester) async { From a8e0129220b52975683433da7353cef472ee8b56 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 20 Sep 2021 13:54:02 -0400 Subject: [PATCH 305/364] [flutter_plugin_tools] Add a federated PR safety check (#4329) Creates a new command to validate that PRs don't change platform interface packages and implementations at the same time, to try to prevent ecosystem-breaking changes. See https://github.com/flutter/flutter/issues/89518 for context. Per the explanation in the issue, this has carve-outs for: - Changes to platform interfaces that aren't published (allowing for past uses cases such as making a substantive change to an implementation, and making minor adjustments to comments in the PI package based on those changes). - Things that look like bulk changes (e.g., a mass change to account for a new lint rule) Fixes https://github.com/flutter/flutter/issues/89518 --- .cirrus.yml | 5 + script/tool/CHANGELOG.md | 3 + .../lib/src/common/git_version_finder.dart | 3 + .../lib/src/common/repository_package.dart | 6 + .../src/federation_safety_check_command.dart | 188 +++++++++++ script/tool/lib/src/main.dart | 2 + .../tool/lib/src/pubspec_check_command.dart | 10 +- .../federation_safety_check_command_test.dart | 314 ++++++++++++++++++ 8 files changed, 523 insertions(+), 8 deletions(-) create mode 100644 script/tool/lib/src/federation_safety_check_command.dart create mode 100644 script/tool/test/federation_safety_check_command_test.dart diff --git a/.cirrus.yml b/.cirrus.yml index 0b3f07da48e1..8dcb4d96d2be 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -74,6 +74,11 @@ task: format_script: ./script/tool_runner.sh format --fail-on-change pubspec_script: ./script/tool_runner.sh pubspec-check license_script: dart $PLUGIN_TOOL license-check + - name: federated_safety + # This check is only meaningful for PRs, as it validates changes + # rather than state. + only_if: $CIRRUS_PR != "" + script: ./script/tool_runner.sh federation-safety-check - name: dart_unit_tests env: matrix: diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 7df6913db7d1..3be9173a505b 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -2,6 +2,9 @@ - `native-test --android`, `--ios`, and `--macos` now fail plugins that don't have unit tests, rather than skipping them. +- Added a new `federation-safety-check` command to help catch changes to + federated packages that have been done in such a way that they will pass in + CI, but fail once the change is landed and published. ## 0.7.1 diff --git a/script/tool/lib/src/common/git_version_finder.dart b/script/tool/lib/src/common/git_version_finder.dart index a0a7a32b5e0f..1cdd2fcc409b 100644 --- a/script/tool/lib/src/common/git_version_finder.dart +++ b/script/tool/lib/src/common/git_version_finder.dart @@ -58,6 +58,9 @@ class GitVersionFinder { return null; } final String fileContent = gitShow.stdout as String; + if (fileContent.trim().isEmpty) { + return null; + } final String? versionString = loadYaml(fileContent)['version'] as String?; return versionString == null ? null : Version.parse(versionString); } diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart index f6601d39b79e..feece7c1cdff 100644 --- a/script/tool/lib/src/common/repository_package.dart +++ b/script/tool/lib/src/common/repository_package.dart @@ -47,6 +47,12 @@ class RepositoryPackage { /// The package's top-level pubspec.yaml. File get pubspecFile => directory.childFile('pubspec.yaml'); + /// True if this appears to be a federated plugin package, according to + /// repository conventions. + bool get isFederated => + directory.parent.basename != 'packages' && + directory.basename.startsWith(directory.parent.basename); + /// Returns the Flutter example packages contained in the package, if any. Iterable getExamples() { final Directory exampleDirectory = directory.childDirectory('example'); diff --git a/script/tool/lib/src/federation_safety_check_command.dart b/script/tool/lib/src/federation_safety_check_command.dart new file mode 100644 index 000000000000..cb0da162e604 --- /dev/null +++ b/script/tool/lib/src/federation_safety_check_command.dart @@ -0,0 +1,188 @@ +// Copyright 2013 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 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +import 'common/core.dart'; +import 'common/file_utils.dart'; +import 'common/git_version_finder.dart'; +import 'common/package_looping_command.dart'; +import 'common/process_runner.dart'; +import 'common/repository_package.dart'; + +/// A command to check that PRs don't violate repository best practices that +/// have been established to avoid breakages that building and testing won't +/// catch. +class FederationSafetyCheckCommand extends PackageLoopingCommand { + /// Creates an instance of the safety check command. + FederationSafetyCheckCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + GitDir? gitDir, + }) : super( + packagesDir, + processRunner: processRunner, + platform: platform, + gitDir: gitDir, + ); + + // A map of package name (as defined by the directory name of the package) + // to a list of changed Dart files in that package, as Posix paths relative to + // the package root. + // + // This only considers top-level packages, not subpackages such as example/. + final Map> _changedDartFiles = >{}; + + // The set of *_platform_interface packages that will have public code changes + // published. + final Set _modifiedAndPublishedPlatformInterfacePackages = {}; + + // The set of conceptual plugins (not packages) that have changes. + final Set _changedPlugins = {}; + + static const String _platformInterfaceSuffix = '_platform_interface'; + + @override + final String name = 'federation-safety-check'; + + @override + final String description = + 'Checks that the change does not violate repository rules around changes ' + 'to federated plugin packages.'; + + @override + bool get hasLongOutput => false; + + @override + Future initializeRun() async { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final String baseSha = await gitVersionFinder.getBaseSha(); + print('Validating changes relative to "$baseSha"\n'); + for (final String path in await gitVersionFinder.getChangedFiles()) { + // Git output always uses Posix paths. + final List allComponents = p.posix.split(path); + final int packageIndex = allComponents.indexOf('packages'); + if (packageIndex == -1) { + continue; + } + final List relativeComponents = + allComponents.sublist(packageIndex + 1); + // The package name is either the directory directly under packages/, or + // the directory under that in the case of a federated plugin. + String packageName = relativeComponents.removeAt(0); + // Count the top-level plugin as changed. + _changedPlugins.add(packageName); + if (relativeComponents[0] == packageName || + relativeComponents[0].startsWith('${packageName}_')) { + packageName = relativeComponents.removeAt(0); + } + + if (relativeComponents.last.endsWith('.dart')) { + _changedDartFiles[packageName] ??= []; + _changedDartFiles[packageName]! + .add(p.posix.joinAll(relativeComponents)); + } + + if (packageName.endsWith(_platformInterfaceSuffix) && + relativeComponents.first == 'pubspec.yaml' && + await _packageWillBePublished(path)) { + _modifiedAndPublishedPlatformInterfacePackages.add(packageName); + } + } + } + + @override + Future runForPackage(RepositoryPackage package) async { + if (!isFlutterPlugin(package)) { + return PackageResult.skip('Not a plugin.'); + } + + if (!package.isFederated) { + return PackageResult.skip('Not a federated plugin.'); + } + + if (package.directory.basename.endsWith('_platform_interface')) { + // As the leaf nodes in the graph, a published package interface change is + // assumed to be correct, and other changes are validated against that. + return PackageResult.skip( + 'Platform interface changes are not validated.'); + } + + // Uses basename to match _changedPackageFiles. + final String basePackageName = package.directory.parent.basename; + final String platformInterfacePackageName = + '$basePackageName$_platformInterfaceSuffix'; + final List changedPlatformInterfaceFiles = + _changedDartFiles[platformInterfacePackageName] ?? []; + + if (!_modifiedAndPublishedPlatformInterfacePackages + .contains(platformInterfacePackageName)) { + print('No published changes for $platformInterfacePackageName.'); + return PackageResult.success(); + } + + if (!changedPlatformInterfaceFiles + .any((String path) => path.startsWith('lib/'))) { + print('No public code changes for $platformInterfacePackageName.'); + return PackageResult.success(); + } + + // If the change would be flagged, but it appears to be a mass change + // rather than a plugin-specific change, allow it with a warning. + // + // This is a tradeoff between safety and convenience; forcing mass changes + // to be split apart is not ideal, and the assumption is that reviewers are + // unlikely to accidentally approve a PR that is supposed to be changing a + // single plugin, but touches other plugins (vs accidentally approving a + // PR that changes multiple parts of a single plugin, which is a relatively + // easy mistake to make). + // + // 3 is chosen to minimize the chances of accidentally letting something + // through (vs 2, which could be a single-plugin change with one stray + // change to another file accidentally included), while not setting too + // high a bar for detecting mass changes. This can be tuned if there are + // issues with false positives or false negatives. + const int massChangePluginThreshold = 3; + if (_changedPlugins.length >= massChangePluginThreshold) { + logWarning('Ignoring potentially dangerous change, as this appears ' + 'to be a mass change.'); + return PackageResult.success(); + } + + printError('Dart changes are not allowed to other packages in ' + '$basePackageName in the same PR as changes to public Dart code in ' + '$platformInterfacePackageName, as this can cause accidental breaking ' + 'changes to be missed by automated checks. Please split the changes to ' + 'these two packages into separate PRs.\n\n' + 'If you believe that this is a false positive, please file a bug.'); + return PackageResult.fail( + ['$platformInterfacePackageName changed.']); + } + + Future _packageWillBePublished( + String pubspecRepoRelativePosixPath) async { + final File pubspecFile = childFileWithSubcomponents( + packagesDir.parent, p.posix.split(pubspecRepoRelativePosixPath)); + final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + if (pubspec.publishTo == 'none') { + return false; + } + + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final Version? previousVersion = + await gitVersionFinder.getPackageVersion(pubspecRepoRelativePosixPath); + if (previousVersion == null) { + // The plugin is new, so it will be published. + return true; + } + return pubspec.version != previousVersion; + } +} diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index e70cba24cc5e..70a6ab516037 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -13,6 +13,7 @@ import 'build_examples_command.dart'; import 'common/core.dart'; import 'create_all_plugins_app_command.dart'; import 'drive_examples_command.dart'; +import 'federation_safety_check_command.dart'; import 'firebase_test_lab_command.dart'; import 'format_command.dart'; import 'license_check_command.dart'; @@ -49,6 +50,7 @@ void main(List args) { ..addCommand(BuildExamplesCommand(packagesDir)) ..addCommand(CreateAllPluginsAppCommand(packagesDir)) ..addCommand(DriveExamplesCommand(packagesDir)) + ..addCommand(FederationSafetyCheckCommand(packagesDir)) ..addCommand(FirebaseTestLabCommand(packagesDir)) ..addCommand(FormatCommand(packagesDir)) ..addCommand(LicenseCheckCommand(packagesDir)) diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 29f9ea733a03..605a8aa83a30 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -210,17 +210,11 @@ class PubspecCheckCommand extends PackageLoopingCommand { // Returns true if [packageName] appears to be an implementation package // according to repository conventions. bool _isImplementationPackage(RepositoryPackage package) { - // An implementation package should be in a group folder... - final Directory parentDir = package.directory.parent; - if (parentDir.path == packagesDir.path) { + if (!package.isFederated) { return false; } final String packageName = package.directory.basename; - final String parentName = parentDir.basename; - // ... whose name is a prefix of the package name. - if (!packageName.startsWith(parentName)) { - return false; - } + final String parentName = package.directory.parent.basename; // A few known package names are not implementation packages; assume // anything else is. (This is done instead of listing known implementation // suffixes to allow for non-standard suffixes; e.g., to put several diff --git a/script/tool/test/federation_safety_check_command_test.dart b/script/tool/test/federation_safety_check_command_test.dart new file mode 100644 index 000000000000..4ae3ec5c76d0 --- /dev/null +++ b/script/tool/test/federation_safety_check_command_test.dart @@ -0,0 +1,314 @@ +// Copyright 2013 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:io' as io; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; +import 'package:flutter_plugin_tools/src/federation_safety_check_command.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'common/plugin_command_test.mocks.dart'; +import 'mocks.dart'; +import 'util.dart'; + +void main() { + FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + + final MockGitDir gitDir = MockGitDir(); + when(gitDir.path).thenReturn(packagesDir.parent.path); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + final List arguments = + invocation.positionalArguments[0]! as List; + // Route git calls through the process runner, to make mock output + // consistent with other processes. Attach the first argument to the + // command to make targeting the mock results easier. + final String gitCommand = arguments.removeAt(0); + return processRunner.run('git-$gitCommand', arguments); + }); + + processRunner = RecordingProcessRunner(); + final FederationSafetyCheckCommand command = FederationSafetyCheckCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + gitDir: gitDir); + + runner = CommandRunner('federation_safety_check_command', + 'Test for $FederationSafetyCheckCommand'); + runner.addCommand(command); + }); + + test('skips non-plugin packages', () async { + final Directory package = createFakePackage('foo', packagesDir); + + final String changedFileOutput = [ + package.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo...'), + contains('Not a plugin'), + contains('Skipped 1 package(s)'), + ]), + ); + }); + + test('skips unfederated plugins', () async { + final Directory package = createFakePlugin('foo', packagesDir); + + final String changedFileOutput = [ + package.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo...'), + contains('Not a federated plugin'), + contains('Skipped 1 package(s)'), + ]), + ); + }); + + test('skips interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + platformInterface.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo_platform_interface...'), + contains('Platform interface changes are not validated.'), + contains('Skipped 1 package(s)'), + ]), + ); + }); + + test('allows changes to multiple non-interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No published changes for foo_platform_interface.'), + contains('Running for foo_bar...'), + contains('No published changes for foo_platform_interface.'), + ]), + ); + }); + + test( + 'fails on changes to interface and non-interface packages in the same plugin', + () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['federation-safety-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('Dart changes are not allowed to other packages in foo in the ' + 'same PR as changes to public Dart code in foo_platform_interface, ' + 'as this can cause accidental breaking changes to be missed by ' + 'automated checks. Please split the changes to these two packages ' + 'into separate PRs.'), + contains('Running for foo_bar...'), + contains('Dart changes are not allowed to other packages in foo'), + contains('The following packages had errors:'), + contains('foo/foo:\n' + ' foo_platform_interface changed.'), + contains('foo_bar:\n' + ' foo_platform_interface changed.'), + ]), + ); + }); + + test('ignores test-only changes to interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('test').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No public code changes for foo_platform_interface.'), + contains('Running for foo_bar...'), + contains('No public code changes for foo_platform_interface.'), + ]), + ); + }); + + test('ignores unpublished changes to interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + // Simulate no change to the version in the interface's pubspec.yaml. + processRunner.mockProcessesForExecutable['git-show'] = [ + MockProcess( + stdout: RepositoryPackage(platformInterface) + .pubspecFile + .readAsStringSync()), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No published changes for foo_platform_interface.'), + contains('Running for foo_bar...'), + contains('No published changes for foo_platform_interface.'), + ]), + ); + }); + + test('allows things that look like mass changes, with warning', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final Directory otherPlugin1 = createFakePlugin('bar', packagesDir); + final Directory otherPlugin2 = createFakePlugin('baz', packagesDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('lib').childFile('foo.dart'), + otherPlugin1.childFile('bar.dart'), + otherPlugin2.childFile('baz.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains( + 'Ignoring potentially dangerous change, as this appears to be a mass change.'), + contains('Running for foo_bar...'), + contains( + 'Ignoring potentially dangerous change, as this appears to be a mass change.'), + contains('Ran for 2 package(s) (2 with warnings)'), + ]), + ); + }); +} From e314c7a8fdcd2e60934fcf7b44402d6f131f012a Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Mon, 20 Sep 2021 20:03:06 +0200 Subject: [PATCH 306/364] [in_app_purchase] Ensure the `introductoryPriceMicros` field is populated correctly. (#4364) --- .../in_app_purchase/in_app_purchase_android/CHANGELOG.md | 4 ++++ .../lib/src/billing_client_wrappers/sku_details_wrapper.dart | 2 +- .../src/billing_client_wrappers/sku_details_wrapper.g.dart | 5 +++-- .../in_app_purchase/in_app_purchase_android/pubspec.yaml | 2 +- .../billing_client_wrappers/sku_details_wrapper_test.dart | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 1a03ba27feb7..7a998d0547de 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.4+7 + +* Ensure that the `SkuDetailsWrapper.introductoryPriceMicros` is populated correctly. + ## 0.1.4+6 * Ensure that purchases correctly indicate whether they are acknowledged or not. The `PurchaseDetails.pendingCompletePurchase` field now correctly indicates if the purchase still needs to be completed. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index 5bbe7504783d..9c349badbb04 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -68,7 +68,7 @@ class SkuDetailsWrapper { final String introductoryPrice; /// [introductoryPrice] in micro-units 990000 - @JsonKey(defaultValue: '') + @JsonKey(name: 'introductoryPriceAmountMicros', defaultValue: '') final String introductoryPriceMicros; /// The number of subscription billing periods for which the user will be given the introductory price, such as 3. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart index 49e86087bc13..d21f832a2de6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -11,7 +11,8 @@ SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { description: json['description'] as String? ?? '', freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', introductoryPrice: json['introductoryPrice'] as String? ?? '', - introductoryPriceMicros: json['introductoryPriceMicros'] as String? ?? '', + introductoryPriceMicros: + json['introductoryPriceAmountMicros'] as String? ?? '', introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', price: json['price'] as String? ?? '', @@ -32,7 +33,7 @@ Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => 'description': instance.description, 'freeTrialPeriod': instance.freeTrialPeriod, 'introductoryPrice': instance.introductoryPrice, - 'introductoryPriceMicros': instance.introductoryPriceMicros, + 'introductoryPriceAmountMicros': instance.introductoryPriceMicros, 'introductoryPriceCycles': instance.introductoryPriceCycles, 'introductoryPricePeriod': instance.introductoryPricePeriod, 'price': instance.price, diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index d9b09827824b..64a40889f375 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+6 +version: 0.1.4+7 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart index b8ba9d5cc854..62d9104f3738 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -134,7 +134,7 @@ Map buildSkuMap(SkuDetailsWrapper original) { 'description': original.description, 'freeTrialPeriod': original.freeTrialPeriod, 'introductoryPrice': original.introductoryPrice, - 'introductoryPriceMicros': original.introductoryPriceMicros, + 'introductoryPriceAmountMicros': original.introductoryPriceMicros, 'introductoryPriceCycles': original.introductoryPriceCycles, 'introductoryPricePeriod': original.introductoryPricePeriod, 'price': original.price, From 19bc12e2a67d7771410addd7e3dccc1727d0ff07 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 21 Sep 2021 08:40:00 +0200 Subject: [PATCH 307/364] [webview_flutter] Extract Android implementation into a separate package (#4343) * Setup webview_flutter_android package. Creates a new `webview_flutter_android` directory and adds the following meta-data files: - `AUTHORS`: copied from the `webview_flutter` package and added my name; - `CHANGELOG.md`: new file adding description for release 0.0.1; - `LICENSE`: copied from the `webview_flutter` package; - `README.md`: new file adding the standard platform implementation description; - `pubspec.yaml`: new file adding package meta-data for the `webview_flutter_android` package. * Direct copy of "android" folder. A one to one copy of the `webview_flutter/android` folder to `webview_flutter_android/` using the following command: ``` cp -R ./webview_flutter/android ./webview_flutter_android/ ``` * Direct copy of Android specific .dart files. Copied the Android specific .dart files over from the `./webview_flutter` package. Note that the `SurfaceAndroidWebView` class in the `./webview_flutter_android/lib/webview_surface_android.dart` file is copied directly (without modifactions) from the `./webview_flutter/lib/webview_flutter.dart` file. * Modify .dart code to work with platform_interface. Make sure the `AndroidWebView` and `SurfaceAndroidWebView` widgets extend the `WebViewPlatform` class from the `webview_flutter_platform_interface` package correctly by accepting an instance of the `JavascriptChannelRegistry` class. * Direct copy of the `webview_flutter/example` app. This commit makes a direct copy of the `webview_flutter/example` app to the `webview_flutter_android` package. After the copy the `example/ios` folder is removed as it doesn't serve a purpose in the Android specific package. Commands run where: ``` cp -R ./webview_flutter/example ./webview_flutter_android/ rm -rf ./webview_flutter_android/example/ios ``` * Update example to Android specific implementation. This commit updates the example App so it directly implements an Android specific implementation of the webview_flutter_platform_interface. * Update integration tests. Updated the existing integration tests (copied from webview_flutter package) so they work correctly with the implementation of the webview_flutter_android package. * Update webview_flutter_platform_interface dependency Updated the pubspec.yaml to depend on version 1.0.0 of the webview_flutter_platform_interface package instead of using a path reference (which is now possible since the platform interface package has now been published). Co-authored-by: BeMacized * Use different bundle ID for Android example app. Make sure the `webview_flutter` and `webview_flutter_android` example apps use different application identifiers so that the CI doesn't run into problems. * Skip flaky integration_tests (issue 86757). * Exlude platform implementations from build all step. Make sure the webview_flutter_android and webview_flutter_wkwebview packages are excluded from the Build All plugins step as they will cause conflicts with the current implementation which is still part of the webview_flutter package. * Split helper classes from main example widget. Move the `WebView` and related `WebViewController` classes from the main.dart into a separate web_view.dart file. Co-authored-by: BeMacized --- .../webview_flutter_android/AUTHORS | 68 + .../webview_flutter_android/CHANGELOG.md | 4 + .../webview_flutter_android/LICENSE | 26 + .../webview_flutter_android/README.md | 12 + .../android/build.gradle | 57 + .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 2 + .../webviewflutter/DisplayListenerProxy.java | 147 ++ .../webviewflutter/FlutterCookieManager.java | 56 + .../FlutterDownloadListener.java | 33 + .../webviewflutter/FlutterWebView.java | 498 ++++++ .../webviewflutter/FlutterWebViewClient.java | 323 ++++ .../webviewflutter/FlutterWebViewFactory.java | 33 + .../webviewflutter/InputAwareWebView.java | 233 +++ .../webviewflutter/JavaScriptChannel.java | 58 + ...readedInputConnectionProxyAdapterView.java | 112 ++ .../webviewflutter/WebViewBuilder.java | 155 ++ .../webviewflutter/WebViewFlutterPlugin.java | 73 + .../FlutterDownloadListenerTest.java | 42 + .../FlutterWebViewClientTest.java | 60 + .../webviewflutter/FlutterWebViewTest.java | 66 + .../webviewflutter/WebViewBuilderTest.java | 104 ++ .../plugins/webviewflutter/WebViewTest.java | 49 + .../webview_flutter_android/example/.metadata | 8 + .../webview_flutter_android/example/README.md | 8 + .../example/android/app/build.gradle | 62 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../flutter/plugins/DartIntegrationTest.java | 14 + .../MainActivityTest.java | 19 + .../webviewflutterexample/WebViewTest.java | 23 + .../android/app/src/debug/AndroidManifest.xml | 17 + .../android/app/src/main/AndroidManifest.xml | 42 + .../WebViewTestActivity.java | 20 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values/styles.xml | 8 + .../example/android/build.gradle | 29 + .../example/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + .../example/android/settings.gradle | 15 + .../example/assets/sample_audio.ogg | Bin 0 -> 36870 bytes .../example/assets/sample_video.mp4 | Bin 0 -> 1053651 bytes .../webview_flutter_test.dart | 1415 +++++++++++++++++ .../example/lib/main.dart | 344 ++++ .../example/lib/navigation_decision.dart | 12 + .../example/lib/navigation_request.dart | 19 + .../example/lib/web_view.dart | 617 +++++++ .../example/pubspec.yaml | 33 + .../example/test_driver/integration_test.dart | 7 + .../lib/webview_android.dart | 62 + .../lib/webview_surface_android.dart | 78 + .../webview_flutter_android/pubspec.yaml | 31 + script/configs/exclude_all_plugins_app.yaml | 6 + 57 files changed, 5128 insertions(+) create mode 100644 packages/webview_flutter/webview_flutter_android/AUTHORS create mode 100644 packages/webview_flutter/webview_flutter_android/CHANGELOG.md create mode 100644 packages/webview_flutter/webview_flutter_android/LICENSE create mode 100644 packages/webview_flutter/webview_flutter_android/README.md create mode 100644 packages/webview_flutter/webview_flutter_android/android/build.gradle create mode 100644 packages/webview_flutter/webview_flutter_android/android/settings.gradle create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/example/.metadata create mode 100644 packages/webview_flutter/webview_flutter_android/example/README.md create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/build.gradle create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/gradle.properties create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/webview_flutter/webview_flutter_android/example/android/settings.gradle create mode 100644 packages/webview_flutter/webview_flutter_android/example/assets/sample_audio.ogg create mode 100644 packages/webview_flutter/webview_flutter_android/example/assets/sample_video.mp4 create mode 100644 packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart create mode 100644 packages/webview_flutter/webview_flutter_android/example/lib/main.dart create mode 100644 packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart create mode 100644 packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart create mode 100644 packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart create mode 100644 packages/webview_flutter/webview_flutter_android/example/pubspec.yaml create mode 100644 packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart create mode 100644 packages/webview_flutter/webview_flutter_android/lib/webview_android.dart create mode 100644 packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart create mode 100644 packages/webview_flutter/webview_flutter_android/pubspec.yaml diff --git a/packages/webview_flutter/webview_flutter_android/AUTHORS b/packages/webview_flutter/webview_flutter_android/AUTHORS new file mode 100644 index 000000000000..4461b602a13b --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/AUTHORS @@ -0,0 +1,68 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom + diff --git a/packages/webview_flutter/webview_flutter_android/CHANGELOG.md b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md new file mode 100644 index 000000000000..d6a10e9b918a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md @@ -0,0 +1,4 @@ +## 2.0.13 + +* Extract Android implementation from `webview_flutter`. + diff --git a/packages/webview_flutter/webview_flutter_android/LICENSE b/packages/webview_flutter/webview_flutter_android/LICENSE new file mode 100644 index 000000000000..77130909e474 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/LICENSE @@ -0,0 +1,26 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/packages/webview_flutter/webview_flutter_android/README.md b/packages/webview_flutter/webview_flutter_android/README.md new file mode 100644 index 000000000000..38838562d13c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/README.md @@ -0,0 +1,12 @@ +# webview\_flutter\_android + +The Android implementation of [`webview_flutter`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `webview_flutter` +normally. This package will be automatically included in your app when you do. + +[1]: https://pub.dev/packages/webview_flutter +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin + diff --git a/packages/webview_flutter/webview_flutter_android/android/build.gradle b/packages/webview_flutter/webview_flutter_android/android/build.gradle new file mode 100644 index 000000000000..4a164317c60f --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/build.gradle @@ -0,0 +1,57 @@ +group 'io.flutter.plugins.webviewflutter' +version '1.0-SNAPSHOT' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 29 + + defaultConfig { + minSdkVersion 19 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + lintOptions { + disable 'InvalidPackage' + disable 'GradleDependency' + } + + dependencies { + implementation 'androidx.annotation:annotation:1.0.0' + implementation 'androidx.webkit:webkit:1.0.0' + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.11.1' + testImplementation 'androidx.test:core:1.3.0' + } + + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/settings.gradle b/packages/webview_flutter/webview_flutter_android/android/settings.gradle new file mode 100644 index 000000000000..5be7a4b4c692 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'webview_flutter' diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..a087f2c75c24 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java new file mode 100644 index 000000000000..31e3fe08c057 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java @@ -0,0 +1,147 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static android.hardware.display.DisplayManager.DisplayListener; + +import android.annotation.TargetApi; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.util.Log; +import java.lang.reflect.Field; +import java.util.ArrayList; + +/** + * Works around an Android WebView bug by filtering some DisplayListener invocations. + * + *

    Older Android WebView versions had assumed that when {@link DisplayListener#onDisplayChanged} + * is invoked, the display ID it is provided is of a valid display. However it turns out that when a + * display is removed Android may call onDisplayChanged with the ID of the removed display, in this + * case the Android WebView code tries to fetch and use the display with this ID and crashes with an + * NPE. + * + *

    This issue was fixed in the Android WebView code in + * https://chromium-review.googlesource.com/517913 which is available starting WebView version + * 58.0.3029.125 however older webviews in the wild still have this issue. + * + *

    Since Flutter removes virtual displays whenever a platform view is resized the webview crash + * is more likely to happen than other apps. And users were reporting this issue see: + * https://github.com/flutter/flutter/issues/30420 + * + *

    This class works around the webview bug by unregistering the WebView's DisplayListener, and + * instead registering its own DisplayListener which delegates the callbacks to the WebView's + * listener unless it's a onDisplayChanged for an invalid display. + * + *

    I did not find a clean way to get a handle of the WebView's DisplayListener so I'm using + * reflection to fetch all registered listeners before and after initializing a webview. In the + * first initialization of a webview within the process the difference between the lists is the + * webview's display listener. + */ +@TargetApi(Build.VERSION_CODES.KITKAT) +class DisplayListenerProxy { + private static final String TAG = "DisplayListenerProxy"; + + private ArrayList listenersBeforeWebView; + + /** Should be called prior to the webview's initialization. */ + void onPreWebViewInitialization(DisplayManager displayManager) { + listenersBeforeWebView = yoinkDisplayListeners(displayManager); + } + + /** Should be called after the webview's initialization. */ + void onPostWebViewInitialization(final DisplayManager displayManager) { + final ArrayList webViewListeners = yoinkDisplayListeners(displayManager); + // We recorded the list of listeners prior to initializing webview, any new listeners we see + // after initializing the webview are listeners added by the webview. + webViewListeners.removeAll(listenersBeforeWebView); + + if (webViewListeners.isEmpty()) { + // The Android WebView registers a single display listener per process (even if there + // are multiple WebView instances) so this list is expected to be non-empty only the + // first time a webview is initialized. + // Note that in an add2app scenario if the application had instantiated a non Flutter + // WebView prior to instantiating the Flutter WebView we are not able to get a reference + // to the WebView's display listener and can't work around the bug. + // + // This means that webview resizes in add2app Flutter apps with a non Flutter WebView + // running on a system with a webview prior to 58.0.3029.125 may crash (the Android's + // behavior seems to be racy so it doesn't always happen). + return; + } + + for (DisplayListener webViewListener : webViewListeners) { + // Note that while DisplayManager.unregisterDisplayListener throws when given an + // unregistered listener, this isn't an issue as the WebView code never calls + // unregisterDisplayListener. + displayManager.unregisterDisplayListener(webViewListener); + + // We never explicitly unregister this listener as the webview's listener is never + // unregistered (it's released when the process is terminated). + displayManager.registerDisplayListener( + new DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + for (DisplayListener webViewListener : webViewListeners) { + webViewListener.onDisplayAdded(displayId); + } + } + + @Override + public void onDisplayRemoved(int displayId) { + for (DisplayListener webViewListener : webViewListeners) { + webViewListener.onDisplayRemoved(displayId); + } + } + + @Override + public void onDisplayChanged(int displayId) { + if (displayManager.getDisplay(displayId) == null) { + return; + } + for (DisplayListener webViewListener : webViewListeners) { + webViewListener.onDisplayChanged(displayId); + } + } + }, + null); + } + } + + @SuppressWarnings({"unchecked", "PrivateApi"}) + private static ArrayList yoinkDisplayListeners(DisplayManager displayManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // We cannot use reflection on Android P, but it shouldn't matter as it shipped + // with WebView 66.0.3359.158 and the WebView version the bug this code is working around was + // fixed in 61.0.3116.0. + return new ArrayList<>(); + } + try { + Field displayManagerGlobalField = DisplayManager.class.getDeclaredField("mGlobal"); + displayManagerGlobalField.setAccessible(true); + Object displayManagerGlobal = displayManagerGlobalField.get(displayManager); + Field displayListenersField = + displayManagerGlobal.getClass().getDeclaredField("mDisplayListeners"); + displayListenersField.setAccessible(true); + ArrayList delegates = + (ArrayList) displayListenersField.get(displayManagerGlobal); + + Field listenerField = null; + ArrayList listeners = new ArrayList<>(); + for (Object delegate : delegates) { + if (listenerField == null) { + listenerField = delegate.getClass().getField("mListener"); + listenerField.setAccessible(true); + } + DisplayManager.DisplayListener listener = + (DisplayManager.DisplayListener) listenerField.get(delegate); + listeners.add(listener); + } + return listeners; + } catch (NoSuchFieldException | IllegalAccessException e) { + Log.w(TAG, "Could not extract WebView's display listeners. " + e); + return new ArrayList<>(); + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java new file mode 100644 index 000000000000..df3f21daadeb --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java @@ -0,0 +1,56 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.webkit.CookieManager; +import android.webkit.ValueCallback; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +class FlutterCookieManager implements MethodCallHandler { + private final MethodChannel methodChannel; + + FlutterCookieManager(BinaryMessenger messenger) { + methodChannel = new MethodChannel(messenger, "plugins.flutter.io/cookie_manager"); + methodChannel.setMethodCallHandler(this); + } + + @Override + public void onMethodCall(MethodCall methodCall, Result result) { + switch (methodCall.method) { + case "clearCookies": + clearCookies(result); + break; + default: + result.notImplemented(); + } + } + + void dispose() { + methodChannel.setMethodCallHandler(null); + } + + private static void clearCookies(final Result result) { + CookieManager cookieManager = CookieManager.getInstance(); + final boolean hasCookies = cookieManager.hasCookies(); + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + cookieManager.removeAllCookies( + new ValueCallback() { + @Override + public void onReceiveValue(Boolean value) { + result.success(hasCookies); + } + }); + } else { + cookieManager.removeAllCookie(); + result.success(hasCookies); + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java new file mode 100644 index 000000000000..cfad4e315514 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java @@ -0,0 +1,33 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.webkit.DownloadListener; +import android.webkit.WebView; + +/** DownloadListener to notify the {@link FlutterWebViewClient} of download starts */ +public class FlutterDownloadListener implements DownloadListener { + private final FlutterWebViewClient webViewClient; + private WebView webView; + + public FlutterDownloadListener(FlutterWebViewClient webViewClient) { + this.webViewClient = webViewClient; + } + + /** Sets the {@link WebView} that the result of the navigation delegate will be send to. */ + public void setWebView(WebView webView) { + this.webView = webView; + } + + @Override + public void onDownloadStart( + String url, + String userAgent, + String contentDisposition, + String mimetype, + long contentLength) { + webViewClient.notifyDownload(webView, url); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java new file mode 100644 index 000000000000..4651a5f5ae22 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -0,0 +1,498 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.view.View; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebStorage; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugin.platform.PlatformView; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class FlutterWebView implements PlatformView, MethodCallHandler { + + private static final String JS_CHANNEL_NAMES_FIELD = "javascriptChannelNames"; + private final WebView webView; + private final MethodChannel methodChannel; + private final FlutterWebViewClient flutterWebViewClient; + private final Handler platformThreadHandler; + + // Verifies that a url opened by `Window.open` has a secure url. + private class FlutterWebChromeClient extends WebChromeClient { + + @Override + public boolean onCreateWindow( + final WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) { + final WebViewClient webViewClient = + new WebViewClient() { + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean shouldOverrideUrlLoading( + @NonNull WebView view, @NonNull WebResourceRequest request) { + final String url = request.getUrl().toString(); + if (!flutterWebViewClient.shouldOverrideUrlLoading( + FlutterWebView.this.webView, request)) { + webView.loadUrl(url); + } + return true; + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (!flutterWebViewClient.shouldOverrideUrlLoading( + FlutterWebView.this.webView, url)) { + webView.loadUrl(url); + } + return true; + } + }; + + final WebView newWebView = new WebView(view.getContext()); + newWebView.setWebViewClient(webViewClient); + + final WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj; + transport.setWebView(newWebView); + resultMsg.sendToTarget(); + + return true; + } + + @Override + public void onProgressChanged(WebView view, int progress) { + flutterWebViewClient.onLoadingProgress(progress); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + @SuppressWarnings("unchecked") + FlutterWebView( + final Context context, + MethodChannel methodChannel, + Map params, + View containerView) { + + DisplayListenerProxy displayListenerProxy = new DisplayListenerProxy(); + DisplayManager displayManager = + (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + displayListenerProxy.onPreWebViewInitialization(displayManager); + + this.methodChannel = methodChannel; + this.methodChannel.setMethodCallHandler(this); + + flutterWebViewClient = new FlutterWebViewClient(methodChannel); + + FlutterDownloadListener flutterDownloadListener = + new FlutterDownloadListener(flutterWebViewClient); + webView = + createWebView( + new WebViewBuilder(context, containerView), + params, + new FlutterWebChromeClient(), + flutterDownloadListener); + flutterDownloadListener.setWebView(webView); + + displayListenerProxy.onPostWebViewInitialization(displayManager); + + platformThreadHandler = new Handler(context.getMainLooper()); + + Map settings = (Map) params.get("settings"); + if (settings != null) { + applySettings(settings); + } + + if (params.containsKey(JS_CHANNEL_NAMES_FIELD)) { + List names = (List) params.get(JS_CHANNEL_NAMES_FIELD); + if (names != null) { + registerJavaScriptChannelNames(names); + } + } + + Integer autoMediaPlaybackPolicy = (Integer) params.get("autoMediaPlaybackPolicy"); + if (autoMediaPlaybackPolicy != null) { + updateAutoMediaPlaybackPolicy(autoMediaPlaybackPolicy); + } + if (params.containsKey("userAgent")) { + String userAgent = (String) params.get("userAgent"); + updateUserAgent(userAgent); + } + if (params.containsKey("initialUrl")) { + String url = (String) params.get("initialUrl"); + webView.loadUrl(url); + } + } + + /** + * Creates a {@link android.webkit.WebView} and configures it according to the supplied + * parameters. + * + *

    The {@link WebView} is configured with the following predefined settings: + * + *

      + *
    • always enable the DOM storage API; + *
    • always allow JavaScript to automatically open windows; + *
    • always allow support for multiple windows; + *
    • always use the {@link FlutterWebChromeClient} as web Chrome client. + *
    + * + *

    Important: This method is visible for testing purposes only and should + * never be called from outside this class. + * + * @param webViewBuilder a {@link WebViewBuilder} which is responsible for building the {@link + * WebView}. + * @param params creation parameters received over the method channel. + * @param webChromeClient an implementation of WebChromeClient This value may be null. + * @return The new {@link android.webkit.WebView} object. + */ + @VisibleForTesting + static WebView createWebView( + WebViewBuilder webViewBuilder, + Map params, + WebChromeClient webChromeClient, + @Nullable DownloadListener downloadListener) { + boolean usesHybridComposition = Boolean.TRUE.equals(params.get("usesHybridComposition")); + webViewBuilder + .setUsesHybridComposition(usesHybridComposition) + .setDomStorageEnabled(true) // Always enable DOM storage API. + .setJavaScriptCanOpenWindowsAutomatically( + true) // Always allow automatically opening of windows. + .setSupportMultipleWindows(true) // Always support multiple windows. + .setWebChromeClient(webChromeClient) + .setDownloadListener( + downloadListener); // Always use {@link FlutterWebChromeClient} as web Chrome client. + + return webViewBuilder.build(); + } + + @Override + public View getView() { + return webView; + } + + // @Override + // This is overriding a method that hasn't rolled into stable Flutter yet. Including the + // annotation would cause compile time failures in versions of Flutter too old to include the new + // method. However leaving it raw like this means that the method will be ignored in old versions + // of Flutter but used as an override anyway wherever it's actually defined. + // TODO(mklim): Add the @Override annotation once flutter/engine#9727 rolls to stable. + public void onInputConnectionUnlocked() { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).unlockInputConnection(); + } + } + + // @Override + // This is overriding a method that hasn't rolled into stable Flutter yet. Including the + // annotation would cause compile time failures in versions of Flutter too old to include the new + // method. However leaving it raw like this means that the method will be ignored in old versions + // of Flutter but used as an override anyway wherever it's actually defined. + // TODO(mklim): Add the @Override annotation once flutter/engine#9727 rolls to stable. + public void onInputConnectionLocked() { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).lockInputConnection(); + } + } + + // @Override + // This is overriding a method that hasn't rolled into stable Flutter yet. Including the + // annotation would cause compile time failures in versions of Flutter too old to include the new + // method. However leaving it raw like this means that the method will be ignored in old versions + // of Flutter but used as an override anyway wherever it's actually defined. + // TODO(mklim): Add the @Override annotation once stable passes v1.10.9. + public void onFlutterViewAttached(View flutterView) { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).setContainerView(flutterView); + } + } + + // @Override + // This is overriding a method that hasn't rolled into stable Flutter yet. Including the + // annotation would cause compile time failures in versions of Flutter too old to include the new + // method. However leaving it raw like this means that the method will be ignored in old versions + // of Flutter but used as an override anyway wherever it's actually defined. + // TODO(mklim): Add the @Override annotation once stable passes v1.10.9. + public void onFlutterViewDetached() { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).setContainerView(null); + } + } + + @Override + public void onMethodCall(MethodCall methodCall, Result result) { + switch (methodCall.method) { + case "loadUrl": + loadUrl(methodCall, result); + break; + case "updateSettings": + updateSettings(methodCall, result); + break; + case "canGoBack": + canGoBack(result); + break; + case "canGoForward": + canGoForward(result); + break; + case "goBack": + goBack(result); + break; + case "goForward": + goForward(result); + break; + case "reload": + reload(result); + break; + case "currentUrl": + currentUrl(result); + break; + case "evaluateJavascript": + evaluateJavaScript(methodCall, result); + break; + case "addJavascriptChannels": + addJavaScriptChannels(methodCall, result); + break; + case "removeJavascriptChannels": + removeJavaScriptChannels(methodCall, result); + break; + case "clearCache": + clearCache(result); + break; + case "getTitle": + getTitle(result); + break; + case "scrollTo": + scrollTo(methodCall, result); + break; + case "scrollBy": + scrollBy(methodCall, result); + break; + case "getScrollX": + getScrollX(result); + break; + case "getScrollY": + getScrollY(result); + break; + default: + result.notImplemented(); + } + } + + @SuppressWarnings("unchecked") + private void loadUrl(MethodCall methodCall, Result result) { + Map request = (Map) methodCall.arguments; + String url = (String) request.get("url"); + Map headers = (Map) request.get("headers"); + if (headers == null) { + headers = Collections.emptyMap(); + } + webView.loadUrl(url, headers); + result.success(null); + } + + private void canGoBack(Result result) { + result.success(webView.canGoBack()); + } + + private void canGoForward(Result result) { + result.success(webView.canGoForward()); + } + + private void goBack(Result result) { + if (webView.canGoBack()) { + webView.goBack(); + } + result.success(null); + } + + private void goForward(Result result) { + if (webView.canGoForward()) { + webView.goForward(); + } + result.success(null); + } + + private void reload(Result result) { + webView.reload(); + result.success(null); + } + + private void currentUrl(Result result) { + result.success(webView.getUrl()); + } + + @SuppressWarnings("unchecked") + private void updateSettings(MethodCall methodCall, Result result) { + applySettings((Map) methodCall.arguments); + result.success(null); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + private void evaluateJavaScript(MethodCall methodCall, final Result result) { + String jsString = (String) methodCall.arguments; + if (jsString == null) { + throw new UnsupportedOperationException("JavaScript string cannot be null"); + } + webView.evaluateJavascript( + jsString, + new android.webkit.ValueCallback() { + @Override + public void onReceiveValue(String value) { + result.success(value); + } + }); + } + + @SuppressWarnings("unchecked") + private void addJavaScriptChannels(MethodCall methodCall, Result result) { + List channelNames = (List) methodCall.arguments; + registerJavaScriptChannelNames(channelNames); + result.success(null); + } + + @SuppressWarnings("unchecked") + private void removeJavaScriptChannels(MethodCall methodCall, Result result) { + List channelNames = (List) methodCall.arguments; + for (String channelName : channelNames) { + webView.removeJavascriptInterface(channelName); + } + result.success(null); + } + + private void clearCache(Result result) { + webView.clearCache(true); + WebStorage.getInstance().deleteAllData(); + result.success(null); + } + + private void getTitle(Result result) { + result.success(webView.getTitle()); + } + + private void scrollTo(MethodCall methodCall, Result result) { + Map request = methodCall.arguments(); + int x = (int) request.get("x"); + int y = (int) request.get("y"); + + webView.scrollTo(x, y); + + result.success(null); + } + + private void scrollBy(MethodCall methodCall, Result result) { + Map request = methodCall.arguments(); + int x = (int) request.get("x"); + int y = (int) request.get("y"); + + webView.scrollBy(x, y); + result.success(null); + } + + private void getScrollX(Result result) { + result.success(webView.getScrollX()); + } + + private void getScrollY(Result result) { + result.success(webView.getScrollY()); + } + + private void applySettings(Map settings) { + for (String key : settings.keySet()) { + switch (key) { + case "jsMode": + Integer mode = (Integer) settings.get(key); + if (mode != null) { + updateJsMode(mode); + } + break; + case "hasNavigationDelegate": + final boolean hasNavigationDelegate = (boolean) settings.get(key); + + final WebViewClient webViewClient = + flutterWebViewClient.createWebViewClient(hasNavigationDelegate); + + webView.setWebViewClient(webViewClient); + break; + case "debuggingEnabled": + final boolean debuggingEnabled = (boolean) settings.get(key); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + webView.setWebContentsDebuggingEnabled(debuggingEnabled); + } + break; + case "hasProgressTracking": + flutterWebViewClient.hasProgressTracking = (boolean) settings.get(key); + break; + case "gestureNavigationEnabled": + break; + case "userAgent": + updateUserAgent((String) settings.get(key)); + break; + case "allowsInlineMediaPlayback": + // no-op inline media playback is always allowed on Android. + break; + default: + throw new IllegalArgumentException("Unknown WebView setting: " + key); + } + } + } + + private void updateJsMode(int mode) { + switch (mode) { + case 0: // disabled + webView.getSettings().setJavaScriptEnabled(false); + break; + case 1: // unrestricted + webView.getSettings().setJavaScriptEnabled(true); + break; + default: + throw new IllegalArgumentException("Trying to set unknown JavaScript mode: " + mode); + } + } + + private void updateAutoMediaPlaybackPolicy(int mode) { + // This is the index of the AutoMediaPlaybackPolicy enum, index 1 is always_allow, for all + // other values we require a user gesture. + boolean requireUserGesture = mode != 1; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + webView.getSettings().setMediaPlaybackRequiresUserGesture(requireUserGesture); + } + } + + private void registerJavaScriptChannelNames(List channelNames) { + for (String channelName : channelNames) { + webView.addJavascriptInterface( + new JavaScriptChannel(methodChannel, channelName, platformThreadHandler), channelName); + } + } + + private void updateUserAgent(String userAgent) { + webView.getSettings().setUserAgentString(userAgent); + } + + @Override + public void dispose() { + methodChannel.setMethodCallHandler(null); + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).dispose(); + } + webView.destroy(); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java new file mode 100644 index 000000000000..260ef8e8b15d --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -0,0 +1,323 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Bitmap; +import android.os.Build; +import android.util.Log; +import android.view.KeyEvent; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.webkit.WebResourceErrorCompat; +import androidx.webkit.WebViewClientCompat; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +// We need to use WebViewClientCompat to get +// shouldOverrideUrlLoading(WebView view, WebResourceRequest request) +// invoked by the webview on older Android devices, without it pages that use iframes will +// be broken when a navigationDelegate is set on Android version earlier than N. +class FlutterWebViewClient { + private static final String TAG = "FlutterWebViewClient"; + private final MethodChannel methodChannel; + private boolean hasNavigationDelegate; + boolean hasProgressTracking; + + FlutterWebViewClient(MethodChannel methodChannel) { + this.methodChannel = methodChannel; + } + + static String errorCodeToString(int errorCode) { + switch (errorCode) { + case WebViewClient.ERROR_AUTHENTICATION: + return "authentication"; + case WebViewClient.ERROR_BAD_URL: + return "badUrl"; + case WebViewClient.ERROR_CONNECT: + return "connect"; + case WebViewClient.ERROR_FAILED_SSL_HANDSHAKE: + return "failedSslHandshake"; + case WebViewClient.ERROR_FILE: + return "file"; + case WebViewClient.ERROR_FILE_NOT_FOUND: + return "fileNotFound"; + case WebViewClient.ERROR_HOST_LOOKUP: + return "hostLookup"; + case WebViewClient.ERROR_IO: + return "io"; + case WebViewClient.ERROR_PROXY_AUTHENTICATION: + return "proxyAuthentication"; + case WebViewClient.ERROR_REDIRECT_LOOP: + return "redirectLoop"; + case WebViewClient.ERROR_TIMEOUT: + return "timeout"; + case WebViewClient.ERROR_TOO_MANY_REQUESTS: + return "tooManyRequests"; + case WebViewClient.ERROR_UNKNOWN: + return "unknown"; + case WebViewClient.ERROR_UNSAFE_RESOURCE: + return "unsafeResource"; + case WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME: + return "unsupportedAuthScheme"; + case WebViewClient.ERROR_UNSUPPORTED_SCHEME: + return "unsupportedScheme"; + } + + final String message = + String.format(Locale.getDefault(), "Could not find a string for errorCode: %d", errorCode); + throw new IllegalArgumentException(message); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (!hasNavigationDelegate) { + return false; + } + notifyOnNavigationRequest( + request.getUrl().toString(), request.getRequestHeaders(), view, request.isForMainFrame()); + // We must make a synchronous decision here whether to allow the navigation or not, + // if the Dart code has set a navigation delegate we want that delegate to decide whether + // to navigate or not, and as we cannot get a response from the Dart delegate synchronously we + // return true here to block the navigation, if the Dart delegate decides to allow the + // navigation the plugin will later make an addition loadUrl call for this url. + // + // Since we cannot call loadUrl for a subframe, we currently only allow the delegate to stop + // navigations that target the main frame, if the request is not for the main frame + // we just return false to allow the navigation. + // + // For more details see: https://github.com/flutter/flutter/issues/25329#issuecomment-464863209 + return request.isForMainFrame(); + } + + boolean shouldOverrideUrlLoading(WebView view, String url) { + if (!hasNavigationDelegate) { + return false; + } + // This version of shouldOverrideUrlLoading is only invoked by the webview on devices with + // webview versions earlier than 67(it is also invoked when hasNavigationDelegate is false). + // On these devices we cannot tell whether the navigation is targeted to the main frame or not. + // We proceed assuming that the navigation is targeted to the main frame. If the page had any + // frames they will be loaded in the main frame instead. + Log.w( + TAG, + "Using a navigationDelegate with an old webview implementation, pages with frames or iframes will not work"); + notifyOnNavigationRequest(url, null, view, true); + return true; + } + + /** + * Notifies the Flutter code that a download should start when a navigation delegate is set. + * + * @param view the webView the result of the navigation delegate will be send to. + * @param url the download url + * @return A boolean whether or not the request is forwarded to the Flutter code. + */ + boolean notifyDownload(WebView view, String url) { + if (!hasNavigationDelegate) { + return false; + } + + notifyOnNavigationRequest(url, null, view, true); + return true; + } + + private void onPageStarted(WebView view, String url) { + Map args = new HashMap<>(); + args.put("url", url); + methodChannel.invokeMethod("onPageStarted", args); + } + + private void onPageFinished(WebView view, String url) { + Map args = new HashMap<>(); + args.put("url", url); + methodChannel.invokeMethod("onPageFinished", args); + } + + void onLoadingProgress(int progress) { + if (hasProgressTracking) { + Map args = new HashMap<>(); + args.put("progress", progress); + methodChannel.invokeMethod("onProgress", args); + } + } + + private void onWebResourceError( + final int errorCode, final String description, final String failingUrl) { + final Map args = new HashMap<>(); + args.put("errorCode", errorCode); + args.put("description", description); + args.put("errorType", FlutterWebViewClient.errorCodeToString(errorCode)); + args.put("failingUrl", failingUrl); + methodChannel.invokeMethod("onWebResourceError", args); + } + + private void notifyOnNavigationRequest( + String url, Map headers, WebView webview, boolean isMainFrame) { + HashMap args = new HashMap<>(); + args.put("url", url); + args.put("isForMainFrame", isMainFrame); + if (isMainFrame) { + methodChannel.invokeMethod( + "navigationRequest", args, new OnNavigationRequestResult(url, headers, webview)); + } else { + methodChannel.invokeMethod("navigationRequest", args); + } + } + + // This method attempts to avoid using WebViewClientCompat due to bug + // https://bugs.chromium.org/p/chromium/issues/detail?id=925887. Also, see + // https://github.com/flutter/flutter/issues/29446. + WebViewClient createWebViewClient(boolean hasNavigationDelegate) { + this.hasNavigationDelegate = hasNavigationDelegate; + + if (!hasNavigationDelegate || android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return internalCreateWebViewClient(); + } + + return internalCreateWebViewClientCompat(); + } + + private WebViewClient internalCreateWebViewClient() { + return new WebViewClient() { + @TargetApi(Build.VERSION_CODES.N) + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, request); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + FlutterWebViewClient.this.onPageStarted(view, url); + } + + @Override + public void onPageFinished(WebView view, String url) { + FlutterWebViewClient.this.onPageFinished(view, url); + } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onReceivedError( + WebView view, WebResourceRequest request, WebResourceError error) { + if (request.isForMainFrame()) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + } + } + + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onWebResourceError(errorCode, description, failingUrl); + } + + @Override + public void onUnhandledKeyEvent(WebView view, KeyEvent event) { + // Deliberately empty. Occasionally the webview will mark events as having failed to be + // handled even though they were handled. We don't want to propagate those as they're not + // truly lost. + } + }; + } + + private WebViewClientCompat internalCreateWebViewClientCompat() { + return new WebViewClientCompat() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, request); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, url); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + FlutterWebViewClient.this.onPageStarted(view, url); + } + + @Override + public void onPageFinished(WebView view, String url) { + FlutterWebViewClient.this.onPageFinished(view, url); + } + + // This method is only called when the WebViewFeature.RECEIVE_WEB_RESOURCE_ERROR feature is + // enabled. The deprecated method is called when a device doesn't support this. + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @SuppressLint("RequiresFeature") + @Override + public void onReceivedError( + @NonNull WebView view, + @NonNull WebResourceRequest request, + @NonNull WebResourceErrorCompat error) { + if (request.isForMainFrame()) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + } + } + + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onWebResourceError(errorCode, description, failingUrl); + } + + @Override + public void onUnhandledKeyEvent(WebView view, KeyEvent event) { + // Deliberately empty. Occasionally the webview will mark events as having failed to be + // handled even though they were handled. We don't want to propagate those as they're not + // truly lost. + } + }; + } + + private static class OnNavigationRequestResult implements MethodChannel.Result { + private final String url; + private final Map headers; + private final WebView webView; + + private OnNavigationRequestResult(String url, Map headers, WebView webView) { + this.url = url; + this.headers = headers; + this.webView = webView; + } + + @Override + public void success(Object shouldLoad) { + Boolean typedShouldLoad = (Boolean) shouldLoad; + if (typedShouldLoad) { + loadUrl(); + } + } + + @Override + public void error(String errorCode, String s1, Object o) { + throw new IllegalStateException("navigationRequest calls must succeed"); + } + + @Override + public void notImplemented() { + throw new IllegalStateException( + "navigationRequest must be implemented by the webview method channel"); + } + + private void loadUrl() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + webView.loadUrl(url, headers); + } else { + webView.loadUrl(url); + } + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java new file mode 100644 index 000000000000..8fe58104a0fb --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java @@ -0,0 +1,33 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.content.Context; +import android.view.View; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; +import java.util.Map; + +public final class FlutterWebViewFactory extends PlatformViewFactory { + private final BinaryMessenger messenger; + private final View containerView; + + FlutterWebViewFactory(BinaryMessenger messenger, View containerView) { + super(StandardMessageCodec.INSTANCE); + this.messenger = messenger; + this.containerView = containerView; + } + + @SuppressWarnings("unchecked") + @Override + public PlatformView create(Context context, int id, Object args) { + Map params = (Map) args; + MethodChannel methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id); + return new FlutterWebView(context, methodChannel, params, containerView); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java new file mode 100644 index 000000000000..51b2a3809fff --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java @@ -0,0 +1,233 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static android.content.Context.INPUT_METHOD_SERVICE; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.webkit.WebView; +import android.widget.ListPopupWindow; + +/** + * A WebView subclass that mirrors the same implementation hacks that the system WebView does in + * order to correctly create an InputConnection. + * + *

    These hacks are only needed in Android versions below N and exist to create an InputConnection + * on the WebView's dedicated input, or IME, thread. The majority of this proxying logic is in + * {@link #checkInputConnectionProxy}. + * + *

    See also {@link ThreadedInputConnectionProxyAdapterView}. + */ +final class InputAwareWebView extends WebView { + private static final String TAG = "InputAwareWebView"; + private View threadedInputConnectionProxyView; + private ThreadedInputConnectionProxyAdapterView proxyAdapterView; + private View containerView; + + InputAwareWebView(Context context, View containerView) { + super(context); + this.containerView = containerView; + } + + void setContainerView(View containerView) { + this.containerView = containerView; + + if (proxyAdapterView == null) { + return; + } + + Log.w(TAG, "The containerView has changed while the proxyAdapterView exists."); + if (containerView != null) { + setInputConnectionTarget(proxyAdapterView); + } + } + + /** + * Set our proxy adapter view to use its cached input connection instead of creating new ones. + * + *

    This is used to avoid losing our input connection when the virtual display is resized. + */ + void lockInputConnection() { + if (proxyAdapterView == null) { + return; + } + + proxyAdapterView.setLocked(true); + } + + /** Sets the proxy adapter view back to its default behavior. */ + void unlockInputConnection() { + if (proxyAdapterView == null) { + return; + } + + proxyAdapterView.setLocked(false); + } + + /** Restore the original InputConnection, if needed. */ + void dispose() { + resetInputConnection(); + } + + /** + * Creates an InputConnection from the IME thread when needed. + * + *

    We only need to create a {@link ThreadedInputConnectionProxyAdapterView} and create an + * InputConnectionProxy on the IME thread when WebView is doing the same thing. So we rely on the + * system calling this method for WebView's proxy view in order to know when we need to create our + * own. + * + *

    This method would normally be called for any View that used the InputMethodManager. We rely + * on flutter/engine filtering the calls we receive down to the ones in our hierarchy and the + * system WebView in order to know whether or not the system WebView expects an InputConnection on + * the IME thread. + */ + @Override + public boolean checkInputConnectionProxy(final View view) { + // Check to see if the view param is WebView's ThreadedInputConnectionProxyView. + View previousProxy = threadedInputConnectionProxyView; + threadedInputConnectionProxyView = view; + if (previousProxy == view) { + // This isn't a new ThreadedInputConnectionProxyView. Ignore it. + return super.checkInputConnectionProxy(view); + } + if (containerView == null) { + Log.e( + TAG, + "Can't create a proxy view because there's no container view. Text input may not work."); + return super.checkInputConnectionProxy(view); + } + + // We've never seen this before, so we make the assumption that this is WebView's + // ThreadedInputConnectionProxyView. We are making the assumption that the only view that could + // possibly be interacting with the IMM here is WebView's ThreadedInputConnectionProxyView. + proxyAdapterView = + new ThreadedInputConnectionProxyAdapterView( + /*containerView=*/ containerView, + /*targetView=*/ view, + /*imeHandler=*/ view.getHandler()); + setInputConnectionTarget(/*targetView=*/ proxyAdapterView); + return super.checkInputConnectionProxy(view); + } + + /** + * Ensure that input creation happens back on {@link #containerView}'s thread once this view no + * longer has focus. + * + *

    The logic in {@link #checkInputConnectionProxy} forces input creation to happen on Webview's + * thread for all connections. We undo it here so users will be able to go back to typing in + * Flutter UIs as expected. + */ + @Override + public void clearFocus() { + super.clearFocus(); + resetInputConnection(); + } + + /** + * Ensure that input creation happens back on {@link #containerView}. + * + *

    The logic in {@link #checkInputConnectionProxy} forces input creation to happen on Webview's + * thread for all connections. We undo it here so users will be able to go back to typing in + * Flutter UIs as expected. + */ + private void resetInputConnection() { + if (proxyAdapterView == null) { + // No need to reset the InputConnection to the default thread if we've never changed it. + return; + } + if (containerView == null) { + Log.e(TAG, "Can't reset the input connection to the container view because there is none."); + return; + } + setInputConnectionTarget(/*targetView=*/ containerView); + } + + /** + * This is the crucial trick that gets the InputConnection creation to happen on the correct + * thread pre Android N. + * https://cs.chromium.org/chromium/src/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnectionFactory.java?l=169&rcl=f0698ee3e4483fad5b0c34159276f71cfaf81f3a + * + *

    {@code targetView} should have a {@link View#getHandler} method with the thread that future + * InputConnections should be created on. + */ + private void setInputConnectionTarget(final View targetView) { + if (containerView == null) { + Log.e( + TAG, + "Can't set the input connection target because there is no containerView to use as a handler."); + return; + } + + targetView.requestFocus(); + containerView.post( + new Runnable() { + @Override + public void run() { + InputMethodManager imm = + (InputMethodManager) getContext().getSystemService(INPUT_METHOD_SERVICE); + // This is a hack to make InputMethodManager believe that the target view now has focus. + // As a result, InputMethodManager will think that targetView is focused, and will call + // getHandler() of the view when creating input connection. + + // Step 1: Set targetView as InputMethodManager#mNextServedView. This does not affect + // the real window focus. + targetView.onWindowFocusChanged(true); + + // Step 2: Have InputMethodManager focus in on targetView. As a result, IMM will call + // onCreateInputConnection() on targetView on the same thread as + // targetView.getHandler(). It will also call subsequent InputConnection methods on this + // thread. This is the IME thread in cases where targetView is our proxyAdapterView. + imm.isActive(containerView); + } + }); + } + + @Override + protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { + // This works around a crash when old (<67.0.3367.0) Chromium versions are used. + + // Prior to Chromium 67.0.3367 the following sequence happens when a select drop down is shown + // on tablets: + // + // - WebView is calling ListPopupWindow#show + // - buildDropDown is invoked, which sets mDropDownList to a DropDownListView. + // - showAsDropDown is invoked - resulting in mDropDownList being added to the window and is + // also synchronously performing the following sequence: + // - WebView's focus change listener is loosing focus (as mDropDownList got it) + // - WebView is hiding all popups (as it lost focus) + // - WebView's SelectPopupDropDown#hide is invoked. + // - DropDownPopupWindow#dismiss is invoked setting mDropDownList to null. + // - mDropDownList#setSelection is invoked and is throwing a NullPointerException (as we just set mDropDownList to null). + // + // To workaround this, we drop the problematic focus lost call. + // See more details on: https://github.com/flutter/flutter/issues/54164 + // + // We don't do this after Android P as it shipped with a new enough WebView version, and it's + // better to not do this on all future Android versions in case DropDownListView's code changes. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P + && isCalledFromListPopupWindowShow() + && !focused) { + return; + } + super.onFocusChanged(focused, direction, previouslyFocusedRect); + } + + private boolean isCalledFromListPopupWindowShow() { + StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace(); + for (StackTraceElement stackTraceElement : stackTraceElements) { + if (stackTraceElement.getClassName().equals(ListPopupWindow.class.getCanonicalName()) + && stackTraceElement.getMethodName().equals("show")) { + return true; + } + } + return false; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java new file mode 100644 index 000000000000..4d596351b3d0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java @@ -0,0 +1,58 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.os.Handler; +import android.os.Looper; +import android.webkit.JavascriptInterface; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; + +/** + * Added as a JavaScript interface to the WebView for any JavaScript channel that the Dart code sets + * up. + * + *

    Exposes a single method named `postMessage` to JavaScript, which sends a message over a method + * channel to the Dart code. + */ +class JavaScriptChannel { + private final MethodChannel methodChannel; + private final String javaScriptChannelName; + private final Handler platformThreadHandler; + + /** + * @param methodChannel the Flutter WebView method channel to which JS messages are sent + * @param javaScriptChannelName the name of the JavaScript channel, this is sent over the method + * channel with each message to let the Dart code know which JavaScript channel the message + * was sent through + */ + JavaScriptChannel( + MethodChannel methodChannel, String javaScriptChannelName, Handler platformThreadHandler) { + this.methodChannel = methodChannel; + this.javaScriptChannelName = javaScriptChannelName; + this.platformThreadHandler = platformThreadHandler; + } + + // Suppressing unused warning as this is invoked from JavaScript. + @SuppressWarnings("unused") + @JavascriptInterface + public void postMessage(final String message) { + Runnable postMessageRunnable = + new Runnable() { + @Override + public void run() { + HashMap arguments = new HashMap<>(); + arguments.put("channel", javaScriptChannelName); + arguments.put("message", message); + methodChannel.invokeMethod("javascriptChannelMessage", arguments); + } + }; + if (platformThreadHandler.getLooper() == Looper.myLooper()) { + postMessageRunnable.run(); + } else { + platformThreadHandler.post(postMessageRunnable); + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java new file mode 100644 index 000000000000..1c865c9444e2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java @@ -0,0 +1,112 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.os.Handler; +import android.os.IBinder; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +/** + * A fake View only exposed to InputMethodManager. + * + *

    This follows a similar flow to Chromium's WebView (see + * https://cs.chromium.org/chromium/src/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnectionProxyView.java). + * WebView itself bounces its InputConnection around several different threads. We follow its logic + * here to get the same working connection. + * + *

    This exists solely to forward input creation to WebView's ThreadedInputConnectionProxyView on + * the IME thread. The way that this is created in {@link + * InputAwareWebView#checkInputConnectionProxy} guarantees that we have a handle to + * ThreadedInputConnectionProxyView and {@link #onCreateInputConnection} is always called on the IME + * thread. We delegate to ThreadedInputConnectionProxyView there to get WebView's input connection. + */ +final class ThreadedInputConnectionProxyAdapterView extends View { + final Handler imeHandler; + final IBinder windowToken; + final View containerView; + final View rootView; + final View targetView; + + private boolean triggerDelayed = true; + private boolean isLocked = false; + private InputConnection cachedConnection; + + ThreadedInputConnectionProxyAdapterView(View containerView, View targetView, Handler imeHandler) { + super(containerView.getContext()); + this.imeHandler = imeHandler; + this.containerView = containerView; + this.targetView = targetView; + windowToken = containerView.getWindowToken(); + rootView = containerView.getRootView(); + setFocusable(true); + setFocusableInTouchMode(true); + setVisibility(VISIBLE); + } + + /** Returns whether or not this is currently asynchronously acquiring an input connection. */ + boolean isTriggerDelayed() { + return triggerDelayed; + } + + /** Sets whether or not this should use its previously cached input connection. */ + void setLocked(boolean locked) { + isLocked = locked; + } + + /** + * This is expected to be called on the IME thread. See the setup required for this in {@link + * InputAwareWebView#checkInputConnectionProxy(View)}. + * + *

    Delegates to ThreadedInputConnectionProxyView to get WebView's input connection. + */ + @Override + public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + triggerDelayed = false; + InputConnection inputConnection = + (isLocked) ? cachedConnection : targetView.onCreateInputConnection(outAttrs); + triggerDelayed = true; + cachedConnection = inputConnection; + return inputConnection; + } + + @Override + public boolean checkInputConnectionProxy(View view) { + return true; + } + + @Override + public boolean hasWindowFocus() { + // None of our views here correctly report they have window focus because of how we're embedding + // the platform view inside of a virtual display. + return true; + } + + @Override + public View getRootView() { + return rootView; + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean isFocused() { + return true; + } + + @Override + public IBinder getWindowToken() { + return windowToken; + } + + @Override + public Handler getHandler() { + return imeHandler; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java new file mode 100644 index 000000000000..d3cd1d57cdae --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java @@ -0,0 +1,155 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import android.content.Context; +import android.view.View; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** Builder used to create {@link android.webkit.WebView} objects. */ +public class WebViewBuilder { + + /** Factory used to create a new {@link android.webkit.WebView} instance. */ + static class WebViewFactory { + + /** + * Creates a new {@link android.webkit.WebView} instance. + * + * @param context an Activity Context to access application assets. This value cannot be null. + * @param usesHybridComposition If {@code false} a {@link InputAwareWebView} instance is + * returned. + * @param containerView must be supplied when the {@code useHybridComposition} parameter is set + * to {@code false}. Used to create an InputConnection on the WebView's dedicated input, or + * IME, thread (see also {@link InputAwareWebView}) + * @return A new instance of the {@link android.webkit.WebView} object. + */ + static WebView create(Context context, boolean usesHybridComposition, View containerView) { + return usesHybridComposition + ? new WebView(context) + : new InputAwareWebView(context, containerView); + } + } + + private final Context context; + private final View containerView; + + private boolean enableDomStorage; + private boolean javaScriptCanOpenWindowsAutomatically; + private boolean supportMultipleWindows; + private boolean usesHybridComposition; + private WebChromeClient webChromeClient; + private DownloadListener downloadListener; + + /** + * Constructs a new {@link WebViewBuilder} object with a custom implementation of the {@link + * WebViewFactory} object. + * + * @param context an Activity Context to access application assets. This value cannot be null. + * @param containerView must be supplied when the {@code useHybridComposition} parameter is set to + * {@code false}. Used to create an InputConnection on the WebView's dedicated input, or IME, + * thread (see also {@link InputAwareWebView}) + */ + WebViewBuilder(@NonNull final Context context, View containerView) { + this.context = context; + this.containerView = containerView; + } + + /** + * Sets whether the DOM storage API is enabled. The default value is {@code false}. + * + * @param flag {@code true} is {@link android.webkit.WebView} should use the DOM storage API. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setDomStorageEnabled(boolean flag) { + this.enableDomStorage = flag; + return this; + } + + /** + * Sets whether JavaScript is allowed to open windows automatically. This applies to the + * JavaScript function {@code window.open()}. The default value is {@code false}. + * + * @param flag {@code true} if JavaScript is allowed to open windows automatically. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setJavaScriptCanOpenWindowsAutomatically(boolean flag) { + this.javaScriptCanOpenWindowsAutomatically = flag; + return this; + } + + /** + * Sets whether the {@link WebView} supports multiple windows. If set to {@code true}, {@link + * WebChromeClient#onCreateWindow} must be implemented by the host application. The default is + * {@code false}. + * + * @param flag {@code true} if multiple windows are supported. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setSupportMultipleWindows(boolean flag) { + this.supportMultipleWindows = flag; + return this; + } + + /** + * Sets whether the hybrid composition should be used. + * + *

    If set to {@code true} a standard {@link WebView} is created. If set to {@code false} the + * {@link WebViewBuilder} will create a {@link InputAwareWebView} to workaround issues using the + * {@link WebView} on Android versions below N. + * + * @param flag {@code true} if uses hybrid composition. The default is {@code false}. + * @return This builder. This value cannot be {@code null} + */ + public WebViewBuilder setUsesHybridComposition(boolean flag) { + this.usesHybridComposition = flag; + return this; + } + + /** + * Sets the chrome handler. This is an implementation of WebChromeClient for use in handling + * JavaScript dialogs, favicons, titles, and the progress. This will replace the current handler. + * + * @param webChromeClient an implementation of WebChromeClient This value may be null. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setWebChromeClient(@Nullable WebChromeClient webChromeClient) { + this.webChromeClient = webChromeClient; + return this; + } + + /** + * Registers the interface to be used when content can not be handled by the rendering engine, and + * should be downloaded instead. This will replace the current handler. + * + * @param downloadListener an implementation of DownloadListener This value may be null. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setDownloadListener(@Nullable DownloadListener downloadListener) { + this.downloadListener = downloadListener; + return this; + } + + /** + * Build the {@link android.webkit.WebView} using the current settings. + * + * @return The {@link android.webkit.WebView} using the current settings. + */ + public WebView build() { + WebView webView = WebViewFactory.create(context, usesHybridComposition, containerView); + + WebSettings webSettings = webView.getSettings(); + webSettings.setDomStorageEnabled(enableDomStorage); + webSettings.setJavaScriptCanOpenWindowsAutomatically(javaScriptCanOpenWindowsAutomatically); + webSettings.setSupportMultipleWindows(supportMultipleWindows); + webView.setWebChromeClient(webChromeClient); + webView.setDownloadListener(downloadListener); + return webView; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java new file mode 100644 index 000000000000..268d35a1e04c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java @@ -0,0 +1,73 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; + +/** + * Java platform implementation of the webview_flutter plugin. + * + *

    Register this in an add to app scenario to gracefully handle activity and context changes. + * + *

    Call {@link #registerWith(Registrar)} to use the stable {@code io.flutter.plugin.common} + * package instead. + */ +public class WebViewFlutterPlugin implements FlutterPlugin { + + private FlutterCookieManager flutterCookieManager; + + /** + * Add an instance of this to {@link io.flutter.embedding.engine.plugins.PluginRegistry} to + * register it. + * + *

    THIS PLUGIN CODE PATH DEPENDS ON A NEWER VERSION OF FLUTTER THAN THE ONE DEFINED IN THE + * PUBSPEC.YAML. Text input will fail on some Android devices unless this is used with at least + * flutter/flutter@1d4d63ace1f801a022ea9ec737bf8c15395588b9. Use the V1 embedding with {@link + * #registerWith(Registrar)} to use this plugin with older Flutter versions. + * + *

    Registration should eventually be handled automatically by v2 of the + * GeneratedPluginRegistrant. https://github.com/flutter/flutter/issues/42694 + */ + public WebViewFlutterPlugin() {} + + /** + * Registers a plugin implementation that uses the stable {@code io.flutter.plugin.common} + * package. + * + *

    Calling this automatically initializes the plugin. However plugins initialized this way + * won't react to changes in activity or context, unlike {@link CameraPlugin}. + */ + @SuppressWarnings("deprecation") + public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) { + registrar + .platformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/webview", + new FlutterWebViewFactory(registrar.messenger(), registrar.view())); + new FlutterCookieManager(registrar.messenger()); + } + + @Override + public void onAttachedToEngine(FlutterPluginBinding binding) { + BinaryMessenger messenger = binding.getBinaryMessenger(); + binding + .getPlatformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/webview", + new FlutterWebViewFactory(messenger, /*containerView=*/ null)); + flutterCookieManager = new FlutterCookieManager(messenger); + } + + @Override + public void onDetachedFromEngine(FlutterPluginBinding binding) { + if (flutterCookieManager == null) { + return; + } + + flutterCookieManager.dispose(); + flutterCookieManager = null; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java new file mode 100644 index 000000000000..2c918584ba83 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java @@ -0,0 +1,42 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.webkit.WebView; +import org.junit.Before; +import org.junit.Test; + +public class FlutterDownloadListenerTest { + private FlutterWebViewClient webViewClient; + private WebView webView; + + @Before + public void before() { + webViewClient = mock(FlutterWebViewClient.class); + webView = mock(WebView.class); + } + + @Test + public void onDownloadStart_should_notify_webViewClient() { + String url = "testurl.com"; + FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); + downloadListener.onDownloadStart(url, "test", "inline", "data/text", 0); + verify(webViewClient).notifyDownload(nullable(WebView.class), eq(url)); + } + + @Test + public void onDownloadStart_should_pass_webView() { + FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); + downloadListener.setWebView(webView); + downloadListener.onDownloadStart("testurl.com", "test", "inline", "data/text", 0); + verify(webViewClient).notifyDownload(eq(webView), anyString()); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java new file mode 100644 index 000000000000..86346ac08f16 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java @@ -0,0 +1,60 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import android.webkit.WebView; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +public class FlutterWebViewClientTest { + + MethodChannel mockMethodChannel; + WebView mockWebView; + + @Before + public void before() { + mockMethodChannel = mock(MethodChannel.class); + mockWebView = mock(WebView.class); + } + + @Test + public void notify_download_should_notifyOnNavigationRequest_when_navigationDelegate_is_set() { + final String url = "testurl.com"; + + FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); + client.createWebViewClient(true); + + client.notifyDownload(mockWebView, url); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockMethodChannel) + .invokeMethod( + eq("navigationRequest"), argumentCaptor.capture(), any(MethodChannel.Result.class)); + HashMap map = (HashMap) argumentCaptor.getValue(); + assertEquals(map.get("url"), url); + assertEquals(map.get("isForMainFrame"), true); + } + + @Test + public void + notify_download_should_not_notifyOnNavigationRequest_when_navigationDelegate_is_not_set() { + final String url = "testurl.com"; + + FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); + client.createWebViewClient(false); + + client.notifyDownload(mockWebView, url); + verifyNoInteractions(mockMethodChannel); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java new file mode 100644 index 000000000000..56d9db1ee493 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java @@ -0,0 +1,66 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class FlutterWebViewTest { + private WebChromeClient mockWebChromeClient; + private DownloadListener mockDownloadListener; + private WebViewBuilder mockWebViewBuilder; + private WebView mockWebView; + + @Before + public void before() { + mockWebChromeClient = mock(WebChromeClient.class); + mockWebViewBuilder = mock(WebViewBuilder.class); + mockWebView = mock(WebView.class); + mockDownloadListener = mock(DownloadListener.class); + + when(mockWebViewBuilder.setDomStorageEnabled(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setJavaScriptCanOpenWindowsAutomatically(anyBoolean())) + .thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setSupportMultipleWindows(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setUsesHybridComposition(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setWebChromeClient(any(WebChromeClient.class))) + .thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setDownloadListener(any(DownloadListener.class))) + .thenReturn(mockWebViewBuilder); + + when(mockWebViewBuilder.build()).thenReturn(mockWebView); + } + + @Test + public void createWebView_should_create_webview_with_default_configuration() { + FlutterWebView.createWebView( + mockWebViewBuilder, createParameterMap(false), mockWebChromeClient, mockDownloadListener); + + verify(mockWebViewBuilder, times(1)).setDomStorageEnabled(true); + verify(mockWebViewBuilder, times(1)).setJavaScriptCanOpenWindowsAutomatically(true); + verify(mockWebViewBuilder, times(1)).setSupportMultipleWindows(true); + verify(mockWebViewBuilder, times(1)).setUsesHybridComposition(false); + verify(mockWebViewBuilder, times(1)).setWebChromeClient(mockWebChromeClient); + } + + private Map createParameterMap(boolean usesHybridComposition) { + Map params = new HashMap<>(); + params.put("usesHybridComposition", usesHybridComposition); + + return params; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java new file mode 100644 index 000000000000..423cb210c392 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java @@ -0,0 +1,104 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.*; + +import android.content.Context; +import android.view.View; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import io.flutter.plugins.webviewflutter.WebViewBuilder.WebViewFactory; +import java.io.IOException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.MockedStatic.Verification; + +public class WebViewBuilderTest { + private Context mockContext; + private View mockContainerView; + private WebView mockWebView; + private MockedStatic mockedStaticWebViewFactory; + + @Before + public void before() { + mockContext = mock(Context.class); + mockContainerView = mock(View.class); + mockWebView = mock(WebView.class); + mockedStaticWebViewFactory = mockStatic(WebViewFactory.class); + + mockedStaticWebViewFactory + .when( + new Verification() { + @Override + public void apply() { + WebViewFactory.create(mockContext, false, mockContainerView); + } + }) + .thenReturn(mockWebView); + } + + @After + public void after() { + mockedStaticWebViewFactory.close(); + } + + @Test + public void ctor_test() { + WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); + + assertNotNull(builder); + } + + @Test + public void build_should_set_values() throws IOException { + WebSettings mockWebSettings = mock(WebSettings.class); + WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + DownloadListener mockDownloadListener = mock(DownloadListener.class); + + when(mockWebView.getSettings()).thenReturn(mockWebSettings); + + WebViewBuilder builder = + new WebViewBuilder(mockContext, mockContainerView) + .setDomStorageEnabled(true) + .setJavaScriptCanOpenWindowsAutomatically(true) + .setSupportMultipleWindows(true) + .setWebChromeClient(mockWebChromeClient) + .setDownloadListener(mockDownloadListener); + + WebView webView = builder.build(); + + assertNotNull(webView); + verify(mockWebSettings).setDomStorageEnabled(true); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(true); + verify(mockWebSettings).setSupportMultipleWindows(true); + verify(mockWebView).setWebChromeClient(mockWebChromeClient); + verify(mockWebView).setDownloadListener(mockDownloadListener); + } + + @Test + public void build_should_use_default_values() throws IOException { + WebSettings mockWebSettings = mock(WebSettings.class); + WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + + when(mockWebView.getSettings()).thenReturn(mockWebSettings); + + WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); + + WebView webView = builder.build(); + + assertNotNull(webView); + verify(mockWebSettings).setDomStorageEnabled(false); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false); + verify(mockWebSettings).setSupportMultipleWindows(false); + verify(mockWebView).setWebChromeClient(null); + verify(mockWebView).setDownloadListener(null); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java new file mode 100644 index 000000000000..131a5a3eb53a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java @@ -0,0 +1,49 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertEquals; + +import android.webkit.WebViewClient; +import org.junit.Test; + +public class WebViewTest { + @Test + public void errorCodes() { + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_AUTHENTICATION), + "authentication"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_BAD_URL), "badUrl"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_CONNECT), "connect"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FAILED_SSL_HANDSHAKE), + "failedSslHandshake"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE), "file"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE_NOT_FOUND), "fileNotFound"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_HOST_LOOKUP), "hostLookup"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_IO), "io"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_PROXY_AUTHENTICATION), + "proxyAuthentication"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_REDIRECT_LOOP), "redirectLoop"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TIMEOUT), "timeout"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TOO_MANY_REQUESTS), + "tooManyRequests"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNKNOWN), "unknown"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSAFE_RESOURCE), + "unsafeResource"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME), + "unsupportedAuthScheme"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_SCHEME), + "unsupportedScheme"); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/.metadata b/packages/webview_flutter/webview_flutter_android/example/.metadata new file mode 100644 index 000000000000..da83b1ada1bd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/.metadata @@ -0,0 +1,8 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 1e5cb2d87f8542f9fbbd0f22d528823274be0acb + channel: master diff --git a/packages/webview_flutter/webview_flutter_android/example/README.md b/packages/webview_flutter/webview_flutter_android/example/README.md new file mode 100644 index 000000000000..850ee74397a9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/README.md @@ -0,0 +1,8 @@ +# webview_flutter_example + +Demonstrates how to use the webview_flutter plugin. + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](https://flutter.dev/). diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle b/packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle new file mode 100644 index 000000000000..1dcd363c9a44 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle @@ -0,0 +1,62 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "io.flutter.plugins.webviewflutterandroidexample" + minSdkVersion 19 + targetSdkVersion 28 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..9a4163a4f5ee --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 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. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java new file mode 100644 index 000000000000..a32aaebb0ecd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutterexample; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@DartIntegrationTest +@RunWith(FlutterTestRunner.class) +public class MainActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java new file mode 100644 index 000000000000..0b3eeef9b6b7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutterexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.webviewflutter.WebViewFlutterPlugin; +import org.junit.Test; + +public class WebViewTest { + @Test + public void webViewPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(WebViewTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(WebViewFlutterPlugin.class)); + }); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..28792201bc36 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..b8c8d38d45a5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java new file mode 100644 index 000000000000..cb53a7a0dbf5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 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. + +package io.flutter.plugins.webviewflutterexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Extends FlutterActivity to make the FlutterEngine accessible for testing. +public class WebViewTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000000..304732f88420 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..00fa4417cfbe --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/build.gradle b/packages/webview_flutter/webview_flutter_android/example/android/build.gradle new file mode 100644 index 000000000000..e101ac08df55 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/gradle.properties b/packages/webview_flutter/webview_flutter_android/example/android/gradle.properties new file mode 100644 index 000000000000..a6738207fd15 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2819f022f1fd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip diff --git a/packages/webview_flutter/webview_flutter_android/example/android/settings.gradle b/packages/webview_flutter/webview_flutter_android/example/android/settings.gradle new file mode 100644 index 000000000000..5a2f14fb18f6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/packages/webview_flutter/webview_flutter_android/example/assets/sample_audio.ogg b/packages/webview_flutter/webview_flutter_android/example/assets/sample_audio.ogg new file mode 100644 index 0000000000000000000000000000000000000000..27e17104277b38ae70d3936f5afb4ea32477ec22 GIT binary patch literal 36870 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 XfG+?r+!9a#U;+;`01|XOAOHdYynO-T literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_android/example/assets/sample_video.mp4 b/packages/webview_flutter/webview_flutter_android/example/assets/sample_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..a203d0cdf13ea5106cdb8c98b52301f0e3719bac GIT binary patch literal 1053651 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%0A0_A2BB?T)i~ z4N2W5nUMXhn#m5ZjZ%8r@Zo^*hAJ$sW*4*wd7e z-^TSy`GRU+6j(e{#3C)$=TSoCvXhQ~i^5G`ofJ5H1Z3HM)UsT1Coa+z;Y7uEc0L$+P?b`&$h#6;Yn%gIcBF+k7a&xe@o$Bh~szTL@SG zT)$gE=cK;ka@n3^lyUynKCW8B-PT8qelkZD(p2E)OHfV`|IC)anf? zXHgh~dn(MW&9GyMCugeU#Cy||Bqd}VgD9hSYV51>!eh1?d3T5-#)3}6ZJUinQXnP^ z;*${1{1F^$7KNfuI$E)L=5yDpAh4BNO+c5$1xW7x#OG3owTv)i_)h!cjohtgw6V

    Jbq-814NPd$TYkZAM*DuEbPgkd!5Mv&pQs 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%0A$ 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

    F^h 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�L$2-vNN2qd&Na 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%0D@5 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%0Ar{$@$bdoE3f zl+)Mctbu8+cf2|ZjG4pJdASDk3rIyAtP$Vil5r_2J=$ajGZKuZ(6c+qweFga?LnP& z$B9ce!%KXsjXq5SRfiPXswS-iY((b=Cco(GpJg%HS)DkqdeAqujLe#wR1zIZXdXP{ z{tH_2aQ2**7FUgJ)s3-;>kvl}STp0|mAA_vm-_%l7@!GoQnEZj3wN(80xE$qG2{SZ zIMDDIsc1tgV(E1RH4YJVhAu$OSF2jgifS7B-^nsAh0uNyKAFA00E3gW={GK;j+f~h zohq|x14})MW>4@JUmZX(s#ELWl|&eClZa?zxfeI^w{P$BU}>beL!G4Rep3Q0XhY|# zxX-5XZ-SNl@9g(p$H;TR8^d%KYV;`5n>Y_aoKvj|F~_3CwYLL;Zix|MBKN2Gc{jQ^juMuW*km>*!flJ3%IgUW|mpYG8n(BlD(= zYaIL2*Z$588bvRM9b$(#z4=TEXV9>co`${%P2`k7B7-~6>N(aHVi(&XpuZ-pXx}8J%at2o*u<+f=PXCZey2HqS=mzo{*yq2fLiL}*XZ zFZeeaaI|A3?OjRz)Z6fr<&iM{;EgKuw)kJtQnzdIf=Lz8g5bD!>Ia zdi`-EPy%YiqM4s6@-AJS@SL0+)PksAdKJAS&*Ey};Xakz&^z;~{8{gTcy=q3YYClv zekUcJ%~sr=N{K9hX!$%3c3!|GFk!pb^G}j!x71<{nI&QBJUn2ymOZw%1&_z{D#Qo< z2$?==`s+b@XX*7fe2!fCvwA{W`$X9{i$w*gsz#xQ>Y{}mFMjgCOEcAfXwK!9Sp-=Z z9Usy4Z)(`b$B#j76y_&2ic<4cA7>hWa6R18Pl|eS%{#6@R=(Vqn!n)X_uW$mKnCW> z-*d;?76M%O1oGjbbt%9pej*EB^N1_ObjuI1Zh4YOKs@ncY-7 z2q=(=jyXe)u=tLTJ3$7Qzr*VJkHf9v#IOz>-5mCkOJz9fJ6>I0PLu7>h~29nGp1bc z8WiO5fOo6hUK4?w{!!hGwh^=aI}NAkma6QANzyC+1_`9i7#!|pW&6^cChdHbS5!S- zH!Ul?HZGc4CHp;M^{4EU@Olj=Bd$d*Z}RFF+fef(Zm?`MYCk7|d`lPn!FWqnQ`AA@ z4IU0gubhAd5BZBo`LAS86*b{r+#st5zw7dilQAoUbQ}twfKkI+qFZQi#`)^;o%Ru2 z^`^CM5Ohh6b9B)zbsa008rLxbYPmm=%ERRw~S9bM$Y0wsrahI#{GgU}6$>h<`*GDSby=9`8*n?2Yz8fw^8tTIp z^;e{gUf#2x;_5Dd#V9R@4JRhisFtJp!mXO!JUJxBWdF~6fNCq36aD{VKFHf$goW8; zT%)x&-uAMS6^e8$<9x{7((ihXPIjHt$77YB`Rek-MQUO+U^;GZ_nlOJ@z7=OISFHx z#^;?KyE;~DK58rpLf2Vm6az0E_wR4dR9h@t$)d*z2RLDaG_rYAX1k%auZNovLX;45 zJi)jrGgyF$L<3rvz=5k~dK zvFg1cs&u-v(42YX^B;@Ie*C`pjq+UIHhg;~EMiC1aF)SLYc}a-XDSlqQ7+o;qZhq8YUzO!c{RRD4xm>%0;ACrnaQ=0 zK)ht{r19Q3{cHDA6-qWhSpg�IeT~@r?-xXXpbi!xq0(h5wcR4zyG}?;yV7-{-lT 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�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 z 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

    ^$ywdz$5hK=ZG`Afn0K9XJjIQXceAoppq9T@ z!hIW!dnAsyealM2*?#daip{H0x$0v-JlQ%Sk0kE_!yodAH;1HR7+U7Ga}Qoh%aXYn z2UMH%LzRPbI@f8!cSi8evyHNCj2G4Rv!VB7{vQeoJ|-zAF9!O5PM?YmCVglygU>lv zwv*Seft-P!hHjHjmE(jJ%D>_h_-@EoX2FbycmM7S@0F*DR<+g1&j}v+QfK-djpXi% zX6_*3OmZCqZsp2=cQ^l>W1+s>lCSlhi@&dCNq`sL?O8h;WGvR74G#`55r*!LS})uY z=DMfq8ANUlU!A$V2=l-PzouaVOL2W#CN*3CcNSHn2>8=PkwSGTaPmeZHP}qh*BbB% z>{&k~)%dUEcS59gwvbJ)eOWoi^LB)2O{>~LW=TTpV`#k3?Th5)IYq}uu|$SKonxuv zy57I6gRVmf9TBdo;NRQ8$ZAa5Erhy>**6j*RXbJDJ*LqjWSf&v-!Blxkzl zQI?#3=h4aI#C4)>?I&-c=bKWn(gRUaakHuV8r}1GHmeFvo)X8&1r-WU=?y5yL=HnM{A)6K10XB`n19 z<7#%DcAc|D$nNyLr`k~F;e=+yuQ~U9_hx@(*X!{^_?P81P^#=r)!Dr4d#`zjqo07C z4+`MbRX8UXi1q&DfBT+4>R-wxl6;ovmUBQsf;LqNbXfBL2*tviIP_}yjY%pxFKD$X zycYT38Yy5tr@*~y9e<}qj(sq9x&!e9*O8lC*JmEQf=|{(84)23=B? zx37_^g+CXGJXTa&ssdvM8Jh0D_yq(BqU%X;_TR6&KlH^daG&ee6_-w<@os(T75lhR z|2f+kHl}dsU@BEnh<%PqeS5ePk(!Hn1xUopSDs@*KQGp=TWN>qIr`*|TCviDj#qFC z5zty1J{-u)OOxouxyc0Z3_o@lP;o?dl+nLbXxZd)U2a#_>gD*DU5VMM++1A%SI+{J zPX>O>D+5y91i!kkni3Z`)^|rNea@AayRVt-?6SP0{0+5jhnmpvv0P4{cw|rZ`?1(~qE|d18v79P#~$-jzY$D)i?XeJfIQZ=e{w zFNObSE7p1>YiPb-<=kmB&R=ZBjJ3!GBJ$xxY^EN=PtJb%PzyMb;ft3pD0G?>z_)B) z+s5J2ywJCiom+co7A}g>2WLa)wAcMg;gAseFS_kPba028ll^dYvQe{@h#OKW>bNg| z^|#)eTR@6yFl?e0%h2kut%4P2&}X%h1z`{W=rJ6X$V9ik`#-F zHuc5ni;MV!Yggy;Rv~e{xYDggR~?>1F||sfpjHFb*KBe`A76C!sIAU<)%%jjjmgD?Our`7h?B<7|*kp3s!b))~DN8C_rQ|iO0w> zY;z-l5te2{2`J=~2k428K2hw%2PNY!w9v@&lPu-a7bK|z@2$z74XS~?siI^xSW_2u zfhwwkhgm7`YeCu)lltcnd_uX|VY8mID_hMb2G9F? z&J7PU%x+pFZ>(K_VZ_>4Eu{body&GsL7usjPps*a_}C=bpMwFHWl2N_3t5ICR*ONL zxi{jOvFRjQq?9T{f6JNlS~f?V14P?Wqc??Zn7y&(6DGnL_v2cBm1(D@LgU7Aca7s|)K|pU@P*4AVtTNETG$jTUh!&S7L*5d0u+P8w zdX)-bAwQF^W@FYI4rc_;Nk5j&pnC^l3o;9~J_bx@)IP@!Ea+K9XAXOIY~k6@>24Pn zK2JvTEi|Zpg>2pE;t|Iyx#7Y(oPEv=3ymci!0%!viwVz+zX;x(wdf0;Rq}*8Jmams zU2<>4mw>5V&R6Csd1~q|gr3Mvl+JJzEhA=;pTGr4XjOtkg}Dwwpypn+^#I=|My*sp zvqVspI;fVM8Ua~M6q~P_{iQ~Tcyp7XJQ-iJx=5>ueFyO25N|ts@FjB7+C=HobUtE0 zcSMc;JwOn5u*HpwtUCR%(`6TKE|z#Z7=_$3b6pF>+9USTrtB;*&>R zyCA>i@hdoo^N82OFLiA;9%oi8B=%DEkE0sUADfdF*^7mSx;?>tOn&W zix(Cn9>bZB59U^b5B_JbfNlI9q4plvXPdJXydbv@^y_`4fEqGnZO?$?iI?Y}8-SCX zjy{OqTWetFprHL&#?P@i-HM#Sz8VJXf&hv(pUR1~P61tYU zxE?4ReDcS7hCxygtXWw|$9|ieY1UpKZRhb|?#EFu z`nDjr`)&M*7^r)n>z%k`-dA>io9%p;s3v-3u-`w8Cem}R3lw!vga7rs8o0bYQZ*m7 z?d`8o(TodT@^z%y%h`8I#`9t{b#ksG$P^28W$=)Hspz2iT$LZ)q)EG`F6I$kVw&*h zH#E0MLIi@Mv3_nmDFbQ34jBT0K)7uBr^ltMtEJbRjno5JNPYB z7KB`hLNQW=-3qn1pD`ikx~guG=Y0S z)j8l?x5<2`nuYu%>l~o9B1#JRZlqGv{YVN;n;i6T3K0o zxSs>T)dYjFkzYPE5iG48p8wGl{F|er1+M(7p9xg#af@xxQQk`3+^N-Yu4uQ}lXr}k zebeF9SbbR1PmzuOZFDE>lqJ*54KcIpX3oRDh4ga&z07UX&#PeNRTar^}&+m z)fy{!A`(ILi=l>r;dB}4r6>MX(a(r8picFvrFqN#^*D4S^_5yOQ`~+@yx+bBRQqCn zR9>2I<9u}O?qNX=<*vM+yIoR0yxZJRj{E1IMua7PHT4lBz8ea)nRKG|@#Erkxu>I_ zt~#={ux5s=y^^*35Q(wb~j`FddMx zKcP*T{6akbQ-sC+Ig7n1D$^?!k;|8?um3X*?_WP9bP5zBNS%9M_W&=Bth7M%jz7D` zptqeG0V#@>HA27X`Q67kxjenHjcQ`F>n!2W;M`<8`OU@tcjl{5PIQ(5?7O?4Uz_6f zW%An__Uz2Oy(U2|5ali>ekn2LT=ykt7L{kc)jod<**~3IV^b!LMfN98NcrrHj72qk zY#855p1#xDqN@^4+7P+g0EPnSjy0Nzv3ieVRwy%uI#L%DfiPH0n~qLqysQtOwR{Rr zC-Gl22wMyu8A`MgLuj;Xz97y=NYlFJfjM6M`2D(~UH#i4w_SWHm8{O%TP z`J$>hHypgMhP9+^_#Ceg0IVz6pnM>H$nU~KeVBQZ%sruZew1ACwzmyhL#S{}qtGli zsu-uIx=4~mWibxjcJvwg>40$;?KvqD3*Jc$#S6%gHn$lfyB-m954jDZMlVw0RN}G* zC#R`wq1J-@?bn`wOJHyitFZK3BK?uMMMD8khOC> zA@|IkDQUWV@0**0kKzxsuwv#WJL8$rc8NJfm##$z8q4q+ea}d|JaEyqpg|6M92i|e z_v-Ek>f$K0gFlbMp_^vIm8bgT*L-T?)Wsu{N`b+7eYkO;_`|PXWC&m^nNL|$KlIna z5u)UhhYhNWw5d*sIEUK~gzqQ%s^_eP@qrB1=bH@*UnHBB*1viVUoV+7ZgZSrZ9V3S z1btZv8jpaizg#Ht@#@^;&=U6 z<9hcwwwM$564U>}pNd;A`YZs9K6b}0rqb1xt<@Tq&8VzDvVMsN(tW$ntlm`1G~t3S zri$m!%>BO_95PQd{n!741`nBTt577PWrYuI6fj<%mhAd9xIg!Oy+V|_?Jcm-#7v;5 z2g1#klYT>*ks+krW47WiPL4DIznG|P3!Yl#cl}`Vet@yui2)X| zZFyiaFjST4OX|H5&j+yf0OkXfgbMtWZtB-I%Fxrek7C1l>n&VC2X;c|KmiW)Kso;O zp}Wo3v6udSv)O-MIrlJFDs86n8bJfma- zO-+fNxKCw;8BHjpii-$dC?k$q+&;4PI7AnfQyN}Beqys_>aSae^+FrVsP>)+yE?4h zX!juun^uf{z+ozK-@}M@fe^wF$D<6~^aUxKaLR^uM=xbf{uc>N*WF0U*{)~y(ic-c z7(jH*eB>j&bNso1^RH`?=%_!%GUzRN#-Gx}C;{~7?JTj!%YZFUR!B;J0iqtSL&BKu zXqd_hU9j8F=E@Nxc>E=Boc2{SA=eRli}H@G>C9&^B62c*l?oHvBB_s5eW=}Bl14bl` z84o3H>5)g-sC3LjL<@9ADzn;Dv~ zaHtbGGCTQzsSu+bB^e1fW-?|I`O}@gqGqFMWSNFVE3c*sOiHXiCE$$7;ep{Wtr8!w z_~Uc#3F@6`XOEf=U9Y6(R1$qVRthUaGhV0oMTh#2M@?X^pQ_K+5N_lAejTPC)hYP+ z=Z}&6yhQEb%$jkf*&su=3{_=r(wx0FGG4}izh7i57-W1y+K8EFzRnNE?5VoL?Hn@I zCaLvZWzGsbsv>%$J^rK+9(#u1*{`Xw_qV8KX`DS?PLwHsEyy;?j(wBWI77R7`l%A+Xo_x(mG;`gYe|WrP?y4s7>$u?Mnvt%S@_e zRLh2K^D;Ka;O8^GF|lV0Z&b(i{?lcZHlZ=)N=!pZ17PRgN%<7~fTZ&UNR#`u-nADsb_ z7Us!uoHDq^$rJEmy-{!>#xS`8!J42$Q(-Lt*mIQ2NsB=Q1B%&d(7J)5Qw&bmrHfNS z-?@IbCq@+}6FuVwrf2$!3i;b9jP zyqupC*5GgRskS+Vi7qz}t$P_1ec$I#EiWHiytp-dXZMlN?vT`CU!`?SVZKesyEA#s zo6*^jq)gTSra1UT%QE)kybWxIj3Lr96jl$`F)uBS5raVl5A@wV*baX?nhw<>OD{evTqtyO ztaHp-x_|mVI=}683j22E-MH6lXtk2kLaX&CY3nBaD6m_nzfqv>FEjRhr%`YF^^;#K z^=h`x{de(_M2bYCqQ~?EJVAjzey;z&BWup@j=20~WV|K(xdvs;5rPfL=14^ov>rxI zj1vVpCAu`ad`ao-=@I3+;A9eV98%vobXu9_Mau;=t##F+S=>x0DZJNs+aJ@v890fM zmU82AAb@mEJu0IF8I+6(TA-0d_WBfn==}RVy`*Lyb(LB!1 zfq|gpk=ngt0RtF1v-=#`Y8Z$1_)harYKDx0yj}sP6S-Bztk4x2g0QMvg7Q3LqBolj z&nJ5fvZ&00C?0dN{D_CpYghUFc69bg|H1JG0(#DS=B$PPg92s$?+!~578j&a8y5VbFOeJGpr{l5O!yeLnI%=9v#wd(2Y(v6t`; zRGTx4;9=?zU&=b`4?E=7!`5#ZGQsVyVDJ`^F=K9sGL~tYk21kfg)GHxg(4F*GXh?% zB;@t#z)2wxg7VggB=s*-PSg2&M&AtsQ~U(A1A93GTRtk`;f z*cJFYKS3|~mV9nGJZ>Kf2v@kp5fDB*JqU7KZYCfdk~!I3yzlt#SCm{s&7H}op88TI zWOraqYctVv76v#e63NlxC~pkkdr=|vBRv3XF-PdEvNf$|_a0m>D#PDFAXRZ}_h zx83{YgG*WAuA3Ea&AElmTbq8{tmoUz(I;u$S;s*1$Lr_kmcTaOt9!aTrk4Y^@yzg5 zxM4Bn^DQAu%+&HViQSLqCg=5e)V)xR($}ac6{I(ilwgiW?;wkjRCYd9r0Wo&E~Tyhji_zA&yDGy(U>~LE@uXV9U zmjw51k?E(Rr-C?7YVi;jkJo1bnP9#tbDqXa9q^Lm`WsV;%H?)pOx0(2#_51+0KWM{1-{te88u%107#Un-mDw7z-Hs+hX}(GfDnD zqaNi*lc=c4y9n^@W&hH4X!xT>hxm|b);Xc6RtU!=!(5I#Xu+$CfZ87Cz=9nC`M)6z zUQ9~Rc|!)#Cq*km-Z-VXvGQFe)%R=(adb&O{s%;liqB0jWeE5083$ z-Z^RZ<#wu!!uNIyf<|)!u`Su_9kD?t=>e8vHIbi<#D6>f_|In@2p5S>UcSMy-~LhA z_&X{IW7I@R-K||;m5mspitvxMEgjEmL{%I2)D}V4B~UuhP)VfDBJ>j6*|qii4x<8` zSjf<2_a}hM6f{1a#sp=%|L06JMS#*WL2Yw$julw^f#=L9CksfQ&os`wJqGuDr(E;; zocCb*LS7SgyLz;1xH0+wmO{4+)nwQhj$PYcP`kov8UA+1vOyslf9 z9I({hK^t_YPGy^+kl0IGLfTcZU^n@Xs~UD=5}0lbix0`NWKfALqy<&!K_F0T;Hknx zq<(t{>}Hi0DdeOjK!*J98;rtagBAm=#U!@3tWrZ@zEOhkwf2{DA1RYv2tSSmDeilQR<1-F+fvEN4Ugz~HXeC*UY&$} z{lc2^pw(t+E4jd9TA(?CH2(`}gHlq!RS-oQv@=#m%E*a`2hk@Adh+4bY(w%_ zyKb49RQh8$&h$$La3sfW=%*RaEHtloyVw7Y-rVm~ubXH1UCZCQH0)E4QSL9d7h=r2 z>nN=!DzdYRxJpz!HP?jYD1hgfDOoAw&kMr#C~T&gOhcKTPf831DQ^$GD`a5`ZTXoR zT;CpVJ+roy>)2K9+j`W<76T1iD4#q0&ABfp{YeD8DE9TotIhy6*j6bPnamlRxK<+T z5|1d>I4^6;<8)>%O&zxm}EjqEy0Jkci5|IaK zJ!#z}fh=C+BaY;y<`Ts1sjTK(dT+IU0zO9%GfwoTbI6BCZf7=K^OH2U@Jj=!3_XqxWKWc>=G-hos7Qg8v4C z0>#uJNM84itBo=+k40D8*foy1#17|X3L(GOYD8p_UEZsFDu3l}=}k5gREeM(x0I}oWV+o<%@JO3_>en_yeC6ju2@=&I*GTcFF_f9rwYZRd&o%~7#nF{d2wjS z)H+U1i_Z%pkdd05p?bZS1o)u7k@g>@J#Mg$JkGGQeScom;2a&M$Es^-JsWDxPFvjoR+G*u(`K>2@&E5Cw z0L2O%gB<15L@mDf;_WC^kvk#sWRQ2P-BxX;fq%0xV%1PD8|lhMdJab4K$q+Y5)IqR zLEZ1V#pVOEz|PvWDtIB3O#O3jqxX>T!mEW$Zt&}SVDO+iYrkoJZ{tdmJ3e8!RGYA+TJtmBcvP`?L`Yh3?s&E_YqrWI0VUJ5CeRp(Q^PA`CDnpjFzOt4*|diIC=xt5$!3$Ke+LpJD(i^n{OF$?w!KI z>a_A*R^kII=0SvjFiu99Vp$o36>x9uv=+TnqDqWDs0m`VM%js}3ubA09NaJxQ=x5k zwV9a89VJPUPFdO)%XxC1(KtTg{hvR%Hg8@=ms8t5AHZBOMBp-mqw=~__zmEVQa5xY z?Kg^0kDi)hQZ7DeLTl=G2DtuJIDoND)|PVBor8Nv_}SudQ+SYWurB}}*nA(YZ30|p zTG+?nj~e$J&0GrP3CQvePR>xMIvi^#=EfL`edYc&UE1;UXs4QYSUdQv z?$#ovkbG%mI{w$O98Jgyf$QIz_Ic~RnodNovvK^dY78eaN!if1g$`cM zf+qM;ybgcHwF%~Abu?H-aDgAd+rc;Ul=nt1TjS<#V6KFYazXQVe9wc|Nch+q6$(3x z(4&9+{9FsJX3N`8vS1J0x2OG^h!x@j&W_2bfr}9ag1YBw&{8Ad2Ir9>Y^-1nmeFWd zdslW;OlZh+R;1L16t!;#^pR!4G!kuG4t!y8mIMcX$*-)9XSM z{sb49HQ8o?OKIUK~N8zscY* zviND6;<1*r5MD6`3XEL^Q%vMfloUh;EmQ=^j__po1lm}ZYT;sl#`BK3+7ZY{6&xzw4U`ElA*21)1 z3wWwVK)?wxO!i7y5s~c?z6^s(-HH_WV93!0%F_YU^TVT<6yJ+xh(0E)OJB0U-weL| zt(`YOlF22uxepMzIhXD%Y+u4y*r!QkxNdfuS)@t>5sL~kk`=))nii@G0n#LhRi70T z5ep*EgjSnY4EIkzt&0v-_vhqke&covH@eH^lv9u3_8GN3L0fe6w`9G;2Y*Wc&6=DX z@mI#bkMIF$7L#E|c3K0ri-aQwmqW7*W37bLM<))68OCcBf4Arq*7;YgAse5qYgpcb6MBS4d2?1jDZo1pZ^2cQOac)l z;QB+pKy3p+G||BS*%AM5sTx#*n}h)kXF?h|Y1n!lIW_;!%Sm!l2`51z1@_d9sD^b) zU@N?p%U1L8M%n1_#?WCn7A3_33XiboFoVYnS;4d4`IV-|{yXIdPQGJv*5|~!x7(Q9 z>jiB0@wfG}vXU`MwE@i;E*%{61zO*4$fBp`Br9uQp2eYS$j>-1p^cd>djunl?}1RVa)GpXd0m$pwZPi@R4euPnJoMp({(VQPLv9_XmO2Q6i)eP$L+9inbc2o(D8x@-@XI(lwV>9h{z7m|jl_+Wfq?GrO9* zg&!C{fpVw))l@T_97|p~^>fO%SHkb#L_Ci8#d>yJw?w@?j=2q6sjBU}LU$Z=yGqowEK6XZk*Ilk85BNAbaa)BJ=b3n}Y!udpk@F+XSD(TZ}`7^ovb!&`djn-pkXU zBdDtDx~|#c@>~DE8Cqh*d@cQ#e^XRiSGqMVkYlSD!#0m`nKg!1ix$>B$J``ziV-MG z(`>*F;v{AhbE$IO@sU94UL{YPRt>cj;y4!j%kOP$264Mt?%caP5;MWxATZ$hqI`Mq z622xq|~ypGov!sg~e2 z9+YY;HfeHVR<~nx>yi~TzLzBSEWRU-@->p1(ZOs-$@4-j(DIT3)FCByj;^s#$}I2E zt6?(pPpcvLb3?3($)kcot%zGK|FtB+41kikX`uTpbOb2MlSWI02n{8QiEC77(|#Pe zzwa?+kXuHrD2qnlNZal6irtw#u?X`cuRi%H<9_?-defr@=rS?P^n7@;RCAo860qN` z*e8#RiHsmpii z`%5m`W=x)GVya7gOA20C?v?qnboCJSHgmtCz6;0V-m$m)Z6}#+=NsQPAT>3YIZZaM zSFt48UGz*5GnuZ$L{6#Gl0!FiC2m_*hdfkF^5AV-pUT#$$uC!_2Xh5ULLXRd`>fAA z3kY_?rt@-=Gm1!-{uOzYElb{Nk>r5nf=^SA-2A3syZGLRwju)@49S~0W&*34L7HI&T0borauxH5IUq?zk)ZKOMsWiEe$EVFB4BE??L=Dm$uj5 ztTVt=7})*Cu={M{Vi0*+x_ikAZOS#h-7_X#Ld)@^!tV4}usVSgtQ;{_l-{6a9`R=X zg7$UH9WP`+8WVsSR*#k z7odV<*Ar`Y=@;!kuT;6^DPAX7BZCJN%tLoVv@_R{n z@iS-~c3zQ-Y^rRLjnXQ!`X7aLHQ)7vlUe|IchA>2!&-uR2%md_Ps$U&IAMjCfB1RB zBa&~*qm44hR$;MZ85)C#1H#-_=;YjN(@z#=Tsl?%srh6uvGch&#%2}9s##R6eJcpo z-0hyvfPkUdN={rvS&%ysH^ls1meZ-dJqpwfU*0BJqZSO`K>_m5ws?}Kts8_sH~HPp zUNpLE?gZ@4Gll>?g-Q1Nu9?;I8_nil;&XgcCpPsR<*x`1{;1bK|G2&=Cmk5*AJQpB zoJP9Z8RWlT7WGH4BYR30pz@IL=#`#)&YAo;-hgB;EwgJ4B(!GjAM#*k|vf-#UwR_EV1D#OlC8{fN~ z^Dy6WD#e?SN#BHPlMoGL%35KE{a*3>8uf|h2`>m7j_cO4{BCU_YY>U&EI5TxuW-Dd z6E_}5Ttnoc31TVY{|>RCkVMmm|9@u0Qo@=9y11lZiVVH{cONx_F6>hy*v#gt{(AH& zrU=;1!A(jJ0D7eFM6N2hF$xcn)0kvHUP2I6L3!1t{K?x)Dtt5?(Te-}WuH02t+(47 zvD_(}$%oPj?*2Lw_P;*ds&t&_8m?pjB>Uw4 zODK;*;^bPWjB-e{-g)A5!lgV2soyV0KSZnyG}vwGRjJTKw=aYx-F~M8`XTBuA;{ss zQ(P_D8SvF5he4q_@Mu&{XZ=;=SqgNLB>;~rz?CIuXpu^rD(ZUxYyE48jF>bcwW`p? z9KE6(Ayv>Mav7TnbM=$kL);N+6bz{5)p{K3(n>7*`j4k{KSjzQ>-L|!LhTIRY2yL6 zL+9E{Nu8>e9t&WDG~y=WJF=a%B@?pfw4V}=x#TZ9((?coN*~SI&hC}N>22`npwf8G z?j4>W~Lp-I*HDsDI7u;cPJQwlj8*ktbu1iT-NCVLrCNTJ-azt9r03OHSu zdRR@IAVI|4Sg5y<8x1OScU@=+!?zr7jht$BcO&Zi!pn}sb_xxD$*IJg_(>*F@fjCw znNS4%5X)Wnf>o=H=RsA_bX7RyW`woMyBc)SMV(rp-CnDQ?;3SZ^wjLTPKG{Sg{zHd zQBIW&dLkj6N$#U(J~g+Vue*v1YEXu@$~K3$a^5qE@~kbdmLi5dxsR8h0KCA4^`Or3 zL8&Q?XwWcLV;0K@pbH`%TY)E?-fLpg4SmxpO%vd|MQ77o%X({3~N|3d1(~L0FbDc8_2^U27N7KuO096PYf^{i1rfV=GLzZyo1qEBmtu zX@%uVLV!+%#TTVD$LA9r>~Fp9odQp+-<#Fxhe%-|_o3-N1T184@h0d= z05xvawr2Yk$}X*K8;AN%7df1&K6y1!#}}m-doSO+FvU(6 zYZg#pk}F%|R{V?}5n2BYdrl+Y?@&TJjtKvb{4~@4_HcLgkcg_zC@+hqyL`esAV9dQ zE}-z8jygp}>qj9TjYz>YFT+#(RSpA+;8vXkK;>iZXh(hb@0BE;<>b)_|DSNo{#hwS z21kDDk};ZK8U|jZr*RJ9%NcvUVC7)r>i-lM->AK; zx_!cMCMaD#J8UhSk;YS0?&U;oEeOAGU7*OfP z@(gELqd9)E-3-ZNwV>t5%yX?_<M?oC{@l)0iDg0g;xde}Qb%tBMAOrJV=E?uJ4r zEYPq(slW=43*GbTz3GbgM^p0@W381hrel`i{iP%^jX7#pMr}OSrtP<}OafjqJZsz! zpkkmpVVokma>wh|wKl}l*8o{tEfGvKR+wTab0gGHsJMqM;FHyWa3Cv43yJ}aDvXXR z_6?0K7a9LJc>1dHnm6QF-DSnAySAM5(O>Q4wY&Dfd*J+i&rs^zUyx{Vtl@R$h5MrU z^Q}L%;_@?|80-C-c3w5_mc&8$4k-}Ypvak{u-nB~?D5E3t-OuCoBEc_JF9+kzRFhr zq#w2`F7ERd+-2^1DgQ{{Ng%I`pK7FYQQ9%2NJv} z1ZZ~v+NMK?1Q`{K(xRir_kZ&NmR;njQcfP^61^EXW?t)TR;U+mcfLdM8Ft>+Rp1=b zpW9Nty7|A`HJraZp9*!mTg07b>REQW>`k*P=9CiNL$4jb`yoTom46hzkid9I2i$W$ zL=s=K5DB!#@)^AOYP4h*k&zO(U6@r<<{8~lR~A3(FG=}HgHsPg$9Ffrv!8$&=6~y( z&6l?dyo(gq0LNAG%LTEV_D|wLKQ$H9ASrP1t_ zX4CYXr8BE%z`p0)4FCAECjrdq0&nqg?fHHq^2uUz!u{Ke=A+T?w&Wel*)G z*q<@t8GMQf{6P8J3GV1Uvl^9_m6WsUwficLJ6Pvc-}wP`;jP~!z8-xg&LgTgz;>xf z2?Fs|;AZ0=HlcOr`uZLl|33*Mhz*1dj}%lOf~xcl^d^ZJW275?HvlAD`Z4GgOzH(qrrs0BD+c* zwHXF5!%!y3Ly{Y+{L*JF*Ov#oN!ZK| zc`B=CGNi57nSIOSF|zI7IXKh`p#_^zv1m3O7O*t&i0*Kfkgq7O4AM zcD$g>yn>}e>sOvdK8JubF(@GU=yjJF`~XPCZA708dKOS$6(2$>)9OyA^Rq$T+2x$J zx;S%5LWu84Aw%c3xf^iR=9`^0bH3Cu-G4Sb+1nz0(M#OTd2-3e`0_kvYk0@bbSlF; z;@VDF+FJBlx+1uTYFt|#5uW_sDn&yWj~{b4Vg3yQ&F5;|*KGJjeSX&jU}>wP_H9CV zVF29}S9JIM>=Hhu{ISXU$_7>&S)PWePU`&^8=^FN2xeux$|Fm02P&xv2i?d+IhjQ~ zCn9_>gz0~777!W>G%Ta(OgIMRM5T4B>+KMox>hbmRpmk0)9!k$^sM<(pnInI@lSXH zfs_@mKfxh8HA%}oD-CoAfYraV%+^1))oLYf0Ylt^b~FC1W9XXNPjhyJFLU%~()vh5 z?b+{NCjD#WT`lSe0mCH_c`tP@YJJkn3F`OIcy0*mpN-R_H3Fift6#^MwQOcRa+Y*b zFY7e%btS_eCa=rP?9vhUabFAI#)rqJarB+Eiv}h5O5o}h?v%wmM@4?gizwFq|J6Gg zCcP3B0?4u;RIen*jvfjn4C>n@NLfN(J*yYHZ-;cMREgBbi+Au?U;ic(8k`@Y?yPER z8D6-3bS>ii^`mJ84t@sj4V!G#RGt3WY&rJwLgwzh6rgPNmZxogK9#?rYASXSlJmQr z7Ib0OhTV|N<36}tfzt6E@pfV~knkj$xsDKt<<^|e5R2Eqc)6h3XTN*ur%eg&?z=ap zPdQshla;D)NL^EHQ=ds^J1UnQ4gk%dc7W&a*XaeIQ3C~NdE75lu0>4_lBt3CYatS( zXdx2De!gY=2CX$O^2f`DaWxMUoZAXa9WBEMyZ-IES?|E5yKmlYQ)};qk8zE6p=*E@ z+L=n%WAcgj)a19Stk@FtgRSCyV_Zw|da%@)c%tdmo=s8QjUgji@kw`(Zf&Vq%KRab zeLx^dE$$Bx;w?W(^wG;wtnp&?cQAX`DtIGpf&M~|aF#XJf%aV_tm z-1)F$6$LL~&ZZwkfcw`dD}6(U&F{~dGHm5pew;J_ROl3^s3_2i-y8dToW18gepz3B z5fH4~Sop5t4DgSvxOhAW-SB*6X?1r=eM{|Hz9cfF8HstVpsSFtlPG%!2!3uX*|pee z{@`99#SsUd_ghNON>-9KB7ymsOAL#h^w3OEK1e8WeZKMV&3zifm2|=D8@>wmiDU(B z6va@n5q43lyv>gEb9Jzktzxp_CWOLQbeOAECNF_ks@$eQaln8|G&WGDEDWpil3BU9 zEW3PKnV4b~GFS7M!5>@nU;yu4)crc+dg9qz39Q+vovyzfu)F9~6!7*?*V`^{MD8d{ ziUX0Q1fw)N1{+frOBQ@kE(GLey+{RstLmR9e}x5xvCbB}_!6z@GTyx>X3yWj`FO5H z9!3g%5Aqa&5hwY+RAVX&JdWljuQCijuFyb(0p@6|bMLe=*m<;|pJzCYnXK%8&ZhW{ zFjlCyU=V+p3H|>I7_`1&$3+C0Famzh%29v@RKhIOArffhJ>R_54GlUHG(q|&R{6(i z*@Yga_*l6wz`|3z?=k{((gK~FX%Qa)H#;#);^a97f-52uZ-1agT(;j-0oSBe^vWOy z%(< zA3e|^M(QsB-D@BpUPKTnbGg_V_D8G68Q=cYc~rOAxJ>c3V;P4xzWpoLaM-!z1QELMVJ9)NjNU&OL zMs3v*n>I&7*1>G9b&JaG)fa|Sfc@L{oI7veQ*@LvjwJWNs-S#SYXn$(Y;V<5fX`<; z<^Za^s0AKSLW9(Y|0QH45KnZe+6Ii`gcBD5Mid;R`B1_p{O1`2eSyT`p+$_9xz$A2 zKxg_njqTT|YUFlpsX=R1iOdMsQ6(>4p2!Pj@1EfE>#LVy@ZtT%wf;t<&mWw^tcKGo zK#|jl3Ay=*#WTv0U+05i)QNC#O!qhcJMDZ0+T(HG?47mWAK^1kk1f#~??v=nToiqRd;iw1Hg1scGWgw$5J|zW2y5d@nyb?b5k+^8`|ydv}vG>vA3!A?Z7s+uR%R192WVC|JEH2yQ)~ z)_KM!NxV_wZ0T61&51IwoEH6vaS!^U^nOD>ex7{!@H?i)P9>W>1jfDk`Vq5uAtN6& zTh?{ywyE~yme#LpPAA%tJdT&f&WcK=;^ zlT*L|zB>wK78d%iz^29p&1?S=UP1mlI2bbK$jN2Jk>h=Lf(2y_ELZCkuS$7vUb?>* zB^56ICen^0X#NEewl%l5?;EjBzB3CN`~C4#+T<9H4{8U>Kh3K-_Kr&*}&g z<9rd$Jo-Q8U#2QMx}s%N4!>~OgJMy=?M!74h^S4{ygAmQ-Y67!yss=hd8c6E-jG z^T@Mij!a0BAvF(dSeDtc{qSmmAd{!4Lm5Uw5F;l*%i$Q!hbIH?V4dZ0{avBJpH&vdYsJrmC|4_mDk`~lyn=y~q z_7>UO@y-FCNyHuXruz(Mek>L?85E{C&*VsK8m1wXAy<}4-BkPCp_vnVo@jn8VI+84 z!my14)h_yHu(0FyQ%AK1KUvTtb@Entk&*M<5*R|NPexov(CC{NzvKv$WTJG0)i10tLJl`XZuGSK3|@^aUNODOzCf{v$Dsi= zk2btadDU$~NUlq7@`39iA~sGR=-a2cI`i!o6;=x3fcFFXeZA)02Q&gUA} zUEzs(-tLEWmJH7K3g3fd_fC|G4ul?ZsF_}hA(p|FV(j%Bw(PV7OxM`;krckAD;yGM zosTabzPg3Zc^Jgp9o|${-05j60rlO}w4)UEOF%8PA`-%KRl=X13EZ%pNcFahvDga3 zvjczs*u#HK4*n8~r4^ia{4w_{GjIXY*hGFeXsh;BbCXb(ANkKcMhgdc)`SSY;X)L+ zd4H`thEW8fI5Ss2IzF~Mx|$R&mFGu=m>x;c8Ew$f#t3R$a z>SL}rYjOaG=m<-}SGG5-59-b^q618tTwM{On-|%Jwb~)!ivYLMsfoBhI zXV!@&5}j{`PCXDPv2DC)43N*KtsS$qQ3;LqG=BZ`l&Ew{gv0B{NV*y@FM_OgF6AFy+PLObZ4nfTOzl# zEDud|p9hS|F>5U2V+(KH)0C~5>%ZOvq5B+91gLN@+!fvKPIgB1y1t00^dXlsJfQWs z?j5->D|$C5P65&|aEK?NF~F1w!^d)>Z=07D(@}S06cX~j1oIkej~`{sPgrza^Grzg z8{rCY@mnV5xeR+i_m~DXcIx%J#af0mqN1&X!zum5=GXw9lq-QIJYgUO!XiB;2x=&- z6#olEP|g`4C~KVZI(2cWQQuRyvX?R>-|Dq|v*wIuGfhp4jMdE5IPbzS-QmbvEq4Kr z>lj&w&-^l-xA;NaYI>42vom3{^LL>yOLOS2_;dPWLb#oEio1hkRnVM2U+dN!=05#| zJpsw5YpXz%Hba%V3#Q?wgB7|rRs=)d*j(8(k_kKJS5o{=@2IGw_)KMTO2$b}bpm(; zi1Lkt51~xZDj&3vgNN8NX#)*I&@scvm?PxKFklnTUc2rO;_UStuROcA99f$aOAUSW zuZ~5>1ywIEYfaCAsBc?2{)+Aqt#uQw^J@p?O~jOO-?6^vNp91=#}HK@1~IfEldisF zNs1DIw|FbS-uoH(h#C2~@@E?=Im1cOk^I$R0vz4^mfw69XeIk>-IeT=c?ju>1E|yi zsb^O1B9yZ3e(NqqZd_RyL(phjjuO468IzT)!Fb4wE;>a(nF$L%0)h5%zvsS!Y~k1t zW1u}9a=W#5C8N5`S*#1=m}^B^DypC4bX#3e;;jmC`*2`8(feTC?cLUFJlWypf`R(Z zm;m8I6~pp;FZ;C-YF)wO(V`rI!Af3Jd4&Y+goB&Mhch!~WqI~fg3N*yOkzdm5?lAT zGRgC;b0l<=>duuo z;Fgl#FRH}Em8wg4p${y7!o;n@dSiitOMd3BQk$R_L%i?kon2Ye@jvI9sf?#3>R6_! zQS^SfY>zAH175dCc>7=H5m$TVbn{lOMJyd(j_))WD&n;mPLYHWF#mPWlz%MuufN;v zJ(>Y}KlHZl$u29Ujc$gYQ-OPgJrRNQC<|wP;YIOQOp<}s)Oy`4qRmmFtjE>`pMn_) zXtYV0M*E&R*mrLeG(o1nkAplv1N-FqlPU)HnA=g%b75)8fgAq%2NDr)~V0Jl_QKCS5kYaZ1$34z>54H*C6|3 zFYHndtauT%2iS`*G6vnr{1zJ?iy5u%!>Dg7*7vJx32(ep|6(zaNmW1`q2?|bMNP{R zggJ8soLCE+f={MU>+WGZ@lMFf!aw=(pwzX6 z6{n-t7Vd%Kfe5w>A>dIu!BmbmUxS6J>epSb;b)6fFnR|tWGig&RSqy-b@|DPj5yAiv1&oC z?uf1vlKwzQ{VGYtiK!Ybbn9Ceh&@pEPggXU+t-p#`8>L^V?P@ zg*O=R^H?p*{OIj+uJz)>Uytr?=GlL|Xwlp#SW--lm?hpPqOk^sOt3Bo?K_2Yf9=!2 zoZqsPh)PoYaY@8MSZTvHJuC6pGYwfN->@a@(9buI-*Cy%q!@CM{9)qMO^Qb2^RoS2 zf8ZMc;py~WOM0M&dPQYRpWfQ%D6*&4aCGOS^jOQ2sdH7jH6k)9(Xf&k(rt_yMZ_;T z)CVFk!Kmssjs@*X+e+>qS1G!vmk&1tSYnEOvOQNh?G^Ssj!&y?t*lyg)9Z~(@LSq( zyLl=!Q^<|6Ykj-2LVot_eHZN<{;ccqazE^_IlasnmSxOcB@9%8@kW+_ShLZyf>{Yf z9el4aIdCO3l_Be>YUa*4X&VQuZhWa@V6pz8rG`B_`3Esk-!4Np*3QhNVqQ6z`_Fj3 zQHLC1m=Xqr6EkkPJTxi=C0j5zcG;!9=I=?4OL9Bzs9pgM(jI^&*u4}BBAaqSC_196 zEGIj&sqiP1!~Z@h7V2=UfaT^Qj8)%ylOZf^x{|tdjP=5}2CX>{_q#R@L^jbmzn+DI zZ{wguoyD8QN2wDWY8)-vqnIPz%CuUk)Cw~7+f3a{x~=sE=i)c}O08P*+h0WMn|A#f zkd@L|oyB{CUW#kHxiV(a*(KQ2Gd8bOYPuFFb)(46;MUi0ked5wiTAEs9cF-V?0EY= z_rfEW&8s414uUS;8jd=ePOoZ138~I29L@G`W%LCM&Dd2AKPv_-qDSRZB>!$q<`t>+ z+%}`o6LWuRMZltnu)u;oDc8WUwU9Zow7iI@A#dak^;^m7;EvDaW+6(&hi0`VERRRK ztF(Rt+ir*BDl_}~gBSek4J%y6TePfb*0o)5o`#o=!!5C7bs)^wH>Szf8O>VLz4;!j zxpsc$vA*rW0o$NvUA@h}EtVaM{V@l1wxPd@)Zi>SMTx$-9@0UNU|36MdUG4Pi#Z#v zxqZKiJbGo;R-AC2-8h<4+{IgLbxq^H_hR(^>MuO{`3mCY4Bc6$yccxYfzP&fY4|F(*752^fh7EI1g&7Z1dM0Ca4NKAVt*jYx`0 z#YLB@U-A8k+87Fw;lAetugl6^B+tJHN{V}TdajYWy>wIz&#`dq zjCl5OJQ*fM1KF;b$dWNWH=d9$Y8~Hnj59Om5YNbF66UWq-y>axVO~S|8c=c zEJ|`$>i1+n55mOf2|Dw6!Cq;I1pN3th8Q zClbm%dcSQyst-jtEwUYuwHt zW`pdGbtTlEw$E=gb7lSK5;O1PeRPK6bux0e{HGr{5HZMuH~mhc0I8bk;QJp zB31&Bomg(-S1|_ChGGi)TDp{5mW?&rTfc*AImWU+x>k#UwLCRWYfr{|g46bbSDCHP zB-M_2%psp5s?;PP7gAO&oE!7e4pfJN$@-V8(FVL|&J_Kj8KzI^8~N^i&D*0ddZn|d zj6}wsZ=QIrQu+tIUzcx>@3v_KGpiv(cQ+%MWzlR80E8~d@BXvUtF<)oNBMdHZtZV- zx#bh-?JQzu02XH`$I6^bJK`!^7C~+an_J~Pub8=vz&NbG?DthfzFljAJopGTI)9{Me+RD|*0&c|WP~eoBy(DeXPs@c)YHsN z^?AxvEICPWR6D%##*~JYt+jW^%khzlRbvKnkT84*&sz$HiPAP${|@~bIv4ToIoD# z_@t(--YBQxE2Y}5+M{ht%S~P#Y0wJ8bAMF3Xr^Ke+GkoUj`oxg2!KErlURinog9mJ zTane{W6ciB+6O(*#*|VA$@rCzbC18OfP4bg2+a*e`9qmrF#?*VS0c{snyj+|dqW^R z(zO9E!6{S{7D1MvcF&>+?>Lffc;QJ(Doz^VFlj{N8%rt&Pm(}3<=9n zXOxn;|5X{q?vKhBq6hXWK=UAr!qXe&)2g>q@8wOmva-IHJB>(<}7KZTeCYY_Jq>MhVoP%-2n zNAo@2?S5&~h=goPk;k1*@ScQ9_m$YN<3)2JfF(eCz1xm578|-dUn-(r>SNFuJm8~tT?W6qi&OJ`3ZFhbkvG5`M;(=WqBmXX& zy&~K5M{{e|DhOH2SRz*Bm)1K9{oe2@v2r&{h}Jja)K9@vCxL4vSLb?qT%o|p;;}QU zh_bmknkG#?)$%3qd))~RQyf^@ZPYnoav?aHh{iVpCOq_;g? zSs^mRZ8p;f;R!nz=O?U6bD!o$vrBSo-r#xRz`N3u%@M71pSHjvRE4MeozjDi!QsDn-9ETExjU-IU6+!sMR1>->p5FaKq^*j&6>7 zkM=_7$PxBp*;}J!%<%x}W}V6dwvyMwDo0%HN6<_clI$CmLaseGVa$IZHl1y;y~ z#vi&Y>44`-7;`=sSTrxmE6c zyPhECn!9>6PdoCK96t%POJeMtzv7&^ne_IK)mr+#1Z7KU=H+-#V^?60RU*wulr*9} z-z$0@qr;lWk-)Y!ML7zC(=XEhTN8wXn|%EjnuZ$`L<*lLFl&gFBmZW0wV=0kxTyX! zGO?fJ7hq!|kYHkDg0ZJ-rS`H&A@UJK1AjHaI@f8LS3Bc5c^w8$E9v9Zw4>>kr2B`L z&^%pQKkz%}Q$`=DP58e?@i2dBoN9*YYsR!-9p*k((WXhdp-(&+~TzL&uC_koyHzd=m*i0J>A zRHUI#ugBaez8*_w&y?~S4^vJVO68d(ibwBk6u0+o(ykmlexJ4kT{O??ZS-AJxYD)HRY7Z-CLxo8mhHlxcQL!ABQOB?^!5OM1nNmXxgL=8eox{M*lw?UTogF= z3ajG=0B<_K1@L^({wI{oRwXX2t*MGmZD|AZ@pMq%wv?Z@5>n43e(A zP#;rU;LHlaUz;2dWyCbbafq=M`+X!Y&S`?wL5?zN%WtJtye6O+Ln--OkV(Y36g8RO zGcKYcdAG&;C7F&htQ9+k(px@|@Uhmrie`S(aWLcz*Hk_MLtJh^d{kT~W;8nYv;tbt zITR4Y5^NmAyh$&HfCkei8kPH+`u6_eu5V_|K?@43!&mD}Yd4T&Jj0~*_Y%w=&aR=6 zZT?i=BiboK9(Qr-vF23o^JexxdH2(Q)}_0miL()Ba3<>V$!oK;@4r{g>li8W6G)y< znG7!z7KoMr%O79$TX3YcBkAKB-w&fJtDR539P-rNgEN?Tcp~GZM_arp`t?S$x?nvz zjdhuxRgV8d!~w&MaKINGew)-<3lR?&71WyiXOJTYVfuuBD-8<6MaX$>mGlGp6rc8gk&y;T~d;svPn0~nt0sBe(3HW_&pPwev*V@|TrNxu3R)EW<&mb$3ev4e=3E>mgQPGZP-CkuQgPh`?&yS_qn z*kCyOD4jyf>R^>zsvmvo#kp(~du_)HFHlx#0=~GpyuBH$wvr+e>Y=Vu3518OdIrH~ zCT9O~f;@AO!T}Aazc7`dAw-z#J-;_tw=Xy5Xr-`z!@x`^u)nuG@ce-D|H2=&UUku_ z%QQUlQna$E5zOAw{>AT<$C#iz|LsCy!+Gu-{TxTiDPOCq&eYXdLeD|IHxfbLZNI)< zC8s1lqeO|} z{j}*L4%e$goUW;XPC8D0cl}BgCoU%8cV1$Z?K!yR(nXC&N20gqRVEqme5a8SW}-h; zXssCYJ~R;_oJOEPI>rCu5C6k}vciXi6`-Yyh3_mrT}O`e5}MXnCd7AD9o?;$c7R6*6sK!SsBjy_I}QwSZTO}wIXD{ zD~`VwtXqG`8zUN8LOoNEr7Lf1&0S`t5v&(S?Cv&GYwuCJTM9a3cWGrnc(F%E+vDA#E_!NrrlhmN?svRMFCI$Wt`{r9Z5qYfg<%fVY0TVV5g1#XY6T-qf-k z@=|-BiX3TT%Gx+;`n+NfL)W6WcyguXkYiVRyG?KIkSMt00Lpb7zo4;kww*Xa-HRk| z^mXNZv>=zeX+i1VX}SYa`3m@vln|B&($=?8(UZl8jw3QUJppm`LfOpNIv~s4zncy8 zXQ(X8|7>PGD{SgMQ>Q3jP^WR7rFBE9g%!-G0HW^%mM!WZbsqfnC#;B@p7hnQx;h>n z{&4AF>)cb|wz};2Fr54Z-oLRo-G{~R3*Pc5)5+_mh!LS6d^{*wAful(sieTAjr{u1 z;z%Y%VRcscyW&GP*3;VU8uqrnItzm*TFD~qizD)*8=sUBf!xLTn+7azueOo`W|O|S zNel(y=;2_MwrhDNSIhiw77OtIfh-9zsX6~e-GCBe{u97P6@Wx6WUaSH496qdN6$8$ z)?VWny6XMvzZTwekpRGDohE;tA8!^$TZ?)Af%;D=$935|qjx>KW3g9D@5sN7aj<5( z{dqKHg${`{lOghqJ38@Q9EHR}lLEanTV8ZywQu#BW0U__n2mnFZzzW`U(=)~ipF{{ z)gDMIKak$ZK1t)g)d#&^BKCZ*hTEVIru8d?!kdfc)B(5_Daz;<2`TP3S<@&n& zDV|F*7|NF7`j;|2ll=8x!!bTRrB`TuqF{;-oUjiWa%gLb%C|t3y7V6ri&(`90G~$iX zK8Q6C&_bBKrE~A^<%^ee+2`#MdXtl5SGdEzBMl-e@B|rp4K;o$Caxj9{=AH;StuD$ zO&~dr!ygMdZjhR&0Hms*{U=EdB?aNQ$x%c4Nj>&!o9nEe_YXO|7qglTR5G}e-2d7Y ze%f z2Y#U{2RBc+6~oXdcjV3eV64~=XTE`C-|2KBLCP`HN$=FUX{=l_Z9S9pXNQp1U=xwO zP|cB+VU(EOlD6@TnmZ}_l5jl)s6!p_V5ui|SF$D-WZ%JBi~_<@$I1l!q87L|^as{s zQ)7V&zyILUDwshad8q++<#MB+m1~!@Cs%BRC3|9$4-((Tr{~O}kKqf+l;kJZ!OdsK z^FxQbH`iWN<}Kxz^MH)*Y`KewzS-ZrufJ61Rb2W#<{EeDw08B4gupisV@+dT6QmgfPM24*#Q^ zfXWcif&Ui_mDX*ZMD+8>RgUq8AEzhM{-|hV2A#wd(OuzS#lg-x4k4W2wHf_==2W9BHVa*3QPkEj%0^`i# z6=&4AylRVYJ1pb=vJ29o$SMiY|NEG(5tlJP-vecHGo70CzKfd|KxQo)*ukCe-^NS#HN6OeD-}K~YrmpQX;00f zA9#;t4C>EMi(D0fcKq+_TRv9cuf`$GJyNCStucVM8G!E3!rVXCIq;ho8t3F3e1a3# zD*4Ap*b#^TxHPWYWPc$fzyFSj_#-8+oPxOg!f!g@KLzot9r zwNuDNw*+cRUTOq268e+^ix83mWFeP@Pxt9OU?1`FPweZ`j7WGswH$1*4k!cSVLcXO zXme53Y2K}}igvDVXN*T9BcjWQ=>+i$Ouqsli_2AbohkZv`x75UT5Y0JafuDs+cS8^Y2iv01b?iN-Ue&e*T4)q=TTUD@xtQi^KUdj4oh(=F7YsCVQx(A zeAsh!pS*Lqpy1_QcI}SblFW(4BxQu$_oc2-pW*`R@g!3xixV(rQtG7pURS6~X zk?gm-ODY=TkA5M(>>qs%dY9>aZir!TX)r&ta=uI|lc!Tw*)WMvHKAX?)OwME0Z>vII zhJ0{?4Q26b0qO6V-2H*TyC%B-3LsWcY#|0TvIt0ZOhJl~6(jF_JAQb7R9Q?`85G(E z{f_e2)4rGnxO1JB(+GU$cMgZi8ruSzwlE7Ga(>(kW~<%)?4-V4Hz-K;_udoqJl@Fx zpkQ`alwzIwHd^^{z4eB^<;bZq{C-EKN6w|)F<^MdY$&Wn`b2@Xtgy3`Ww>^u;Ap_o zs0n)jS=SRbsN@6dF2%8BU6}jBSaQzRRnnpa$qCO5Xi6IfwTl3dFFh-vD?d;wJaaJL zHP~@+pCSXJF@{F z!N)(e-vx9!k+1~Q_?%~&dr|$jOFf`RfvfgFM4?0APlm-QDk|TlFAQ%hXF$0h8c%}m z$ATogUUjLIz^{s^mjUhOQ@vvzm5tGywKL*gKiFn)Q>b;dkQwX#Mi0<_DbDbrroA<& z)@Ck%F`!tIQN5h+&hhv=If+a3`{6Sk=S!}o7ExBUid=$wTZkoNXW$%2#tY^5xwG^1 zZRj7uDju6J6lBc>)%#jNWyFIIqq&;p6?0H>j0+432BcXx8!?) z;()qy9YGNb|2C+Y6Mx9Lz{Pi=x#q?dfvc~fhVGpNiglIBgp2Q1?_}y(y9vl1ay{PM zRW0|dG=MCNaA2EuH+6|nG4@d)mt}P_Y?=yeM|4F&F={G4BFsMtpq7#ZC{rBd6iY8> zMNJU_cdt3u+jdN(6ibn1tdfnV=HemP@=K$gnYrZe+h%IcU3Xdfta~{EnAj0C95$+& zFO}Pdt{jga_U2V-=o1`VUSxaRx`3hd2xr}!EA$7?YTYNDvMTitlzD$PkWN0|5sz|8*QHQm+>#e3^(kH9zOjtVQ$(PK!nEvw>Q;?l<3JWAW>C@n9{^Vd z8y~ShkTgPy%)O_yPOke5;CxivW0T=3J=tLi*=zh(qx)&}cR1_$a!a!6XQ!Y4?L)2I z5XxuY!#K$^sy~woFXeUGKz2lsth;^wl60y2kwe&vwReygPN4;` z>&La0Zkq*mY}K0PbyHqeiEW~+-6j^@)lU!r`-T>MG+P8^G4S>0s2t)Cn-A~LbvptRu<>{C_19~wK5n*H`E@0%2u#7{BZ@mcKlj3CRmm2P7<-K-w*XR_pua=7F5vTUzpfH~A4U3Ja6# z+iQ2@W9ioW13(gwW5UQ~qj#B%2Y^$!Qbf6ntK1+IdZ~6WJUmMaCgP#L4rk@#-5{i+ zc?&$PI=r1y9|h?iu3a3aJjXp_asIfJJ^!||z3A@BU_E`?UT)K+nPj3*WE#o+aUwm> zs#?hrkc@H6ocnA{I@Wf-Y{>L4G6>+b{@#$e> zyfa(uKy#57msSX&yUQCCUtinVJ!A0Qx##N^%$q7%nHeDRzozqk)$aA3iI1(DVaQpv ztnvp)o6T$1wGuiZDU#K#l;hm5r=2~`KHgMPA3Z$D1Vv+oKG$$xJo2^C0zQ5U6&ia~ zWzz~naTjk`@D|uQenml9Xf%1%!&Uv=q!IZ%wBg)Jbgn} znTyb=7zD$qS{NZu*zfHWk0<~CAur$5=#?~Npo9K{eyG5Q^iwfs4%bq;CRPRA)t;tS zaP={230uNwU?>_;EyvOR<|pzLl=t)Fb?n*MV7xCA(?74&<(&BGD{78h__^}IVFSw~ zxZ`+e;YH9yx@vp&>HjJU{@!`Xd;SYP;as~mxN}7xewp~>tCaZ-^=V%Vum8Z4Eo9p4 zkFK1%?3v;8$xs19v^*||^~q9Wm&!<0oE?A3!En=ZU6Dm9zXF)BjSP-eB!I#VZO2cA z6Vw!#;~e07kH(O2Aj>WY;0SVlfY?mbl<=Ul@J~F(hA@KQ+Ii5fFK5*l>)YAUrLQhq zl^Hnp8ubf-kX)RfGR%YZqQKYvT)w{VK`PreRC|( zZe;%1yYjv-|FiOT)3yB@%UgoH1l9t(?lib4a$x!AcKeNjq0y$7E9zG2<9Q}}c3Qmo zc&}2zjd81{>6*-`p8k&tFA9rl>J2sFUxUw(o@8px^B=tfm^$y|%7R^hN7QlYlu~2TnrJ^#K|N&|>02gTAia4qG#}=Oa7qWpOrX>Q2R#6$)&@qr$hJ z-$`FSTQSeKg2|`9|6lw{b?5!Yn3l;`VqHjT>~Y7P_0g`6g9cDoS+ckbV3md#f5$s(uq} zhe}S1@No%BqcQtOyZzU*>A?NtB1Mpe9MCCrE>j$9_Ap^CQRq9UC_vZiLjpfVcIJQ# zPg@lvC<5Pr2Edl@wP)4^OFLm+ZWFynmwR?xl-^&eV{6ej^+jpS%J?Q_I^FO6NM98O`5IoaFK-t1QlI*C0^yMaTXj=8MgU8x z4d=Nx7$WRW>V4`Ag6P-YOR0&I8b3}_1i@$7uqWR{&6J*53@az*X&3%PnVp_>i}Nwq zs>ghfD6pQe5&0Gj4w5V~<0HZaLxLXdgcBYyh@3g%4}E#sfLrv?I696B16go$=$e0my4 zpS0e7+ntryl0j8Hd^yNp)>(b{dRlMeg4=uT<^C4Tafec7o#kR!*Yg@Kf-0NU!6Mry zbtcmXOnOff2sU7}3v+I-Ggg2kPu9J;TC=UY77|_YBqnSk z)izq+mo>a=x69=nW_~!D6D8|wpG!rHAKRx?mnWo$3z?f+Oi7W@|0sa9odWY6wP4F0Xv450Nvx2(j%Z;_tAs z&@__u3(Fn>n`Js_b<0rfbs?!Nt^@=|Vm&j76Qm`QI{-rpLzJ~$S#%1P zrbqo-ky7iJqWOdFnXjTGb4-@@uUj^|ne(cH%%j8>JITvavDbQhS@gUV)4H9mH5!$T z0?iTPXjAX7k2$a?dwlr3*s{|ZCRrfI(%Z(TV!5fwuF$-?Udyg=qNPJ|hefI&FAPZ$ znaLRU!v4NkbuPGK$Yn5}QSDU1oZ*;qmT=M5nyz2KVzc*jBkZZ|5DYn%evVmq{-P?h z*HKpDfkL2}a-nsmZ8iwI6Pn*vIuz-A9+sflfprYG#=|>G#?bV~qM^SADwUCMjlUX1 zp&X%$E<+_{Sfu>|Z&o1|P*Jw>&1U^~E;CWaZ91CpZE;ZJU|qAi8=x0y6bS#@H(QN* zllNU?6QWIu0{|dPJbO>9eSd|?I8DZpB@RyZqYs#%V6P#)VZITLhsW*&#M&wtX>8}$ zil<&dsC#&9hokhMXVVooP-orN`;I6Trf40DO$2A!G(~7Q2f4Nshu(~e5k_aRUy(N; z-30<-=O>wtiOeN)iYKHVsSh?kE}VD-(d|i!0$1+!hz_A$Uj?I`ypK?h_wwd252WfP zwc;2(?-@ubdt5?RnP?WQNVbzkrnb~uuUMO0L^Cu~YPy$WayL3!NyXFf2*ds#V`mi= zR~vL`G`PFFyL)hgySoH;cSs2C!QI^n?k>UIB|va@8tIU}kpJv}SN>R>Ii}=Y26U*~p$NQ&uOz2Ooj zr*idnqyv@9(!n__YY*M_dQ_q3+1d3nGchPBcBG70#AI>3GwdpQXa?Nm2I}aWc5cVa z8sBF6b=!Gw<}JA#w=!BGpKEs`L(xT&I@GI|?)S=t!kR>lF%&^7E=JeDKb%nC{KdQ> z67eDLAtW+CKrfrzrVAFZNjNI>ok{j;5u!&xkUJT8O|zS54?rT)aJ_`3$)UWHO5BjtT5`2w0y{FRKz(oe z+AoIk?Gkbr=377N4MwZ!{153^T-lV)tXfQy7pz8}`tM~#W{>4+zQWJqFjXl*0$Qg? zMETV*3eDr9fUWi6?V#L*-oIgUe?JS$@+?N=z|LPaLmTHcj!)3!wxGFviLi&9hO!GP z%T4Z~E^}l^8kg(G1a1oYyakRu*Xfa+-kG!}q`<6*Is=%WWE5v%;3g3zmQpf9-znRJ zho4}#GE5l}{tWh4=s*!u7My#FN-w0#myoGjX%Hf74h~N|DJ~9&u1wn?46oAEd#uJe zbvvic(MC}|HxeEH+$T+C6aAe9x9cDd)gR>OY%2nHf=@p_dK*#!nF^U5n7gw@b`Ak4 zCL|^cfdEMkzYyH_BE)UZ<(nkOPrz8as7C7IE#}gbO0@kW!QQo_%&OA_NBaj15(_TM zk4KHh`5hJIn!*bv|6u<%YZa;Lnkt8>D7J~=FA{NHh*=y~+lFb%vH)hnaH5c}L!_C= zKSMB+2Ly%YYh+$pwDDqAn{(v3k0lBYpwT$ut54te92bvmvD@XV(C}tEr-|uGODVIO zP(38NtcIHxwbjek)9Magd7vZ{4J}ciX371QUrz{6CrcI333y&Gno#tJxs65CW6Zog$1FFQ?&?GL3 zJb*>{CqHr4m7JWqj08fT`=?64p?pjUiPpJ`+fk8eisT}m&*C0GE+DBP z^xP0s%@h0-uBO`34M4&4LEe4$AvLf^fIvn|2k}N03DT4ux&%%Z%?5Q2h9Q4bC80$I zgE$9dG}PeD$$@VQYe#a(`8_m)X+DR4dtbc?cgnQfxX^ z(L-=(_n6TaWfRYFRX1VgzTK`;)eB-`6GqIb+W09sb+eMhCL-#{lHz4=RM%Kma_m)n4C)^62}h)OuM6ZdQl6CLX|7bN95H;oy*-+`>hQCJCz% zSj953XHr5fEu8&rJYVP!QU_l#sII(j=ESD)?PlgUi5 zyij*M8l%zOD_%T;T;h$jlx9Pv^=DM<{cocRRxSq`DqnP3WX~`R+EokFIc;@4H%~kJ zE4>4E^sbEDFaM^mR(0B<~{^G5Ml>6K&@QLz(^xE{OaNm5Z*r z!j;LqKvH)0H41%*Spx$z$AO;>?Dx>*aAl=U=N%F8P7&oQ`4_!+# zToM;A+Yv!ja7)^Q9z(d1TEw_^jMeD?@voI2+!JaST3*9^BdMyH6 zUca?9#Mhq|`HQxjG|D?Hqt(vrF6|YZT7+fWWo4w^7o4|eJkto5_+HVvjou2DCgPKj zzYNf7(S8>P@FK`0cOWqmq2r_?AW+(t$3P9jBa#w8szCS0Vpd0YWXZ-0(g-^;mL;3d zR0=KY6&mU7R5B6FahpKIa6=WIQL)PsLhOfxF#WlhkNk}^JxK?B{wVb#EBdSAQS18Z zOGOI*#m&OZCs2U39$XR!@(gB|veQawjs0UE|K&Nf%wBF=7N(;GPdZ_INQL_ybVomt z?bp>pCyht!8yQ58ownspD)|cXwi^S@QTke#4$rnUN$muMLUx#67{AaG_ky_aRBpX= za~ShpfDozb4xO#fGC!K1D5aRIEo#j?YEv2HB}xE6hVGvTsOYa_8OGIAQGNn+rsT6L|)?4O&8PV zG&4o85_b3Da`zfKD|0o%_Ea#3@?+(SY?Ne$Eqw(|zHD`(am0O5H8I_x0n zh-l+_#~waDBxN7LLkQ6NfDb>1+wyihzPNxC&XWPj^&>SeJp=NHNCt9Z=eZKT-;DS` zI4Iw;?pUg>rT4%x-OVm&kg|bBC3e5tCT(n9+m|j|5)g&Qfp1~jcg<@OIZ+<+T@EPw znYRI6{|E57Zx_S@S*TkOD}aOuGmr>=czh&`!9sC*MQSSeIK`(Kks#n}pYi&%eSoo% z^s=p*x~o2}NuSM5M_D-V4n!O$w|3G&(w2QRzkDJF$|H-%b_ptPVkEnDPEyQ7HQW@< zTe7>CePJUUsXq#?lPf*&K5)06ONpTJHY~_l`AiIRRBl+Unq;GC%cz|j-0L1q&&6Z< zrOWwwRZJGSk^IzG(3`WcFp+{NZuSj>pY~{CA%IRC+xJyu+aY` z0w-L;Ffhe4QIS%IOUc>q=};%B^ZGpZ#g%gbX4fy-jG^q<=20m8^fM9EgJ;WnEcc{e67Kq9DdaSoj{zsQnkyyZ1@I>{KO zs(_1>9IL<{x1SiF9kh|;ss@psmMN!N1CJ+)LS??yDtSAWwWZ=foP?Y8O@72clYjf^ zUcdP`|Er)wX@BK&4NJ-8i4ijD3vomH%b$s%=R0YqooK?8wUo<4oy=iTBYoOO0@o_jlrx zKArgTpUrG62)qlgN1x=F7q01rU{SS_PFpwQ;Td+ocxF!(`o@lLht5znl#So1iJb;s z^VbW~13=Il{*?+IyyrzV(IQe|KW|@y_GFJW>;I;{ZBzsGShV20IIlgs6g1ym(B1YK`P}%IxY0^lP;%z2(zL-|MT-X31;;b7)AM z3H4oLt=AGC7WaR9^9*i^QImX@o0bE7_STOyFVhK`;edBV1##Mstk|=c1`1k>42zq( z$``nV)Y~ayCg=G#z)R5P-tL>q+IBr5R2tKsM!HX**E81OXqElJ#qe!1WK;FNhpq2{ z+vNm1AhF!#XJevUmk3~6IQ4v~FXy$<#<961&qiXSY}CyHlV z{Xd5ycO3tQB4_}i4|Hr;Mj9l33Pk%$v|ig5FT+B-s+l&uVcn3=&usI3i^??$hfFtm z?E6T>k`beQB;}xIA0l1^LHfoQ{H|&o4BbZD8nwdwn&zc#Pq%KC-A@L)wo@cm_w%hN zJ!f$YD)5(Bdw3a<(TywxILgE8PNv>P93>!iJ{2}%4u-;w;vVKTA)8?M)H@AyptM;q z{SI#6@`?DwhXgA;y!7r*xK6}vN`4Q@&@I=iXW|-$G8NqR(k^1N0Og-iVTR^+klepBWjP5>_NEEPObou{ zx4A*C&YC~DE6VqMiNBtE_~Q~b{404>-FAS4zDY&9K5dYc2efZD3QVuNBEtzeGClYd zGCMf3`^#8h?!421ShgwQh*!YlJrOz3Fof*#4*_Dt48{F9uDjPsOf?s_+Fan^_uJY} zJh*=%l>;|MycZRXuD&@mb4gPC;1HFSDf#-9R0Q$#l!)V5NpxHQw-mI_fw1L*NWGPe zBgf7~@i$@GxI6T|2>Vt1crzN#85M~2y>eKCU#vkmyRuG4R%q-Gif*Xb8jQq4-m=w-_upCycV?eLs)LWhj5amBB9T(pw6X%qspy7 zQ}_r2&=5B2(9R+DZM4~%{dvyql8@I}|54x)8vdwm9PL~5%X!*4h2ngPjq+7D5Usu{=E*uHhP*{ngICELGT)^~e)|KqlfDIC_L)qoI&kx%d>S14&Tb=1fCM`N z2?pKJ;~<1VnW<)gv6e_#`Sn4;&ZE|=?xWR)fIR+@ZD*aXN+&L{K$Y_JI~L=FSpib4 zy{hL!5zJ@N-CLcnT^&1JUG%Rzy-1Vy*Y~`ut9aED)J#eKB~3LhkkWKcvm-2tF1w#a zUyKZaBjvuDElg)NVIM)Q3>xlPZnehsCs)LOBEM$ze$J4!+!Az)F~-XGpO=#=N`(?srM~ z%iiyAd(OUgR+k>d zfJ(3`8~fd&U>ppnEbXzJ6`9u2OG@W7yg5asj!yaH7AcB036jF=&RRYRmV>baIhD0w zdGi#;QN5Si7Ps_%)bf?p)mR4Z*mut6Y_cu%?U^k3lDxOiht_W-vqW(B{Bbq9ADlBr zq|t1&a(ggLQ06%+vod|RzPWn!dCM=b2wEClp>ldTJLp(^sUEAp-5*eN|2lM|U%Sw` zTNul^K3ulQGp_Hp!uc|ajTJUwns$&}my_pFt{)1IQu;PIXn-)eM zrASGRv|ZOWdhJ(T#cnk2PJQ!)%*5rmF@(D(2)0jKhN-`X)%GYs^n{H&ytC!sNST{X40aP5VQkqJ-J37 zikCCOAI8C-cwM)_=>dwFH*9BeLFXhc#?%w+1>1*tt_RQ7J4pUs#fP}7B%lF}9lP7b zrI<%E;2FC9ib*GWmr|s}a+fkCmJfm?Oe71LqwN63|M*j>vB{YUQNrQGP5U!cWl#`) zh?|ZZD&inTK^1b)$C~4~8*kY^z5A@@UAK2H-+S8puHBNWnbYY4fs)A*5YC2qpymSW z&tD$%(RRgfRCjMVOg_loGcajM>qsk(#qCdXF+vf zh8>OM5km4Lz0=b-T?aDoE3gQ2P4GPhUeY0TW*AR9!>y^p_bdNR0sua8d*f4nXDAbH zufE;rk5ANpJ_y_RYK>kv(bDqmIvhEGX@FcWuvbG$l;mM|kWzlPw;l43a7(|x?(BkOEkJ9Vf!P#bJC zFR~wtUGB#|7z4s($JL4>)=M$*BoN6$8NWC|f@1bVOwM&_{)92#0Fx1run|MRNgGpU z@U;_~!nFS@Js!B3Dl}`?Wu$};9i{R+cXU19>*c!z-1m0%cAVSYZyQxs8JuvweQ`5h z1A-)GUaQY3nyFeJLJ5*y+zJCtCwjCPj?hE?e+ud})^eWUgEK zYif_qg0`~V>gY6fj=58lzeY)y*b!1w2u>YoYzN=Y*lh1F!%3C{-Z8;_*-G9d;PIUl z0z!%4D9C!|%uQRHK{Dqjc`-Oxpb&_4tsf~7^{AlP5P9~n28kr-_E3v9`eN$nuUo($ zWT)0hmbY1<@}gXe=&?lH1op&=8i9Zxx2=rWC{d8+V!Dy|;Pvr^rUjamX|4COtKn5O z+qYev+b+qhSMs}Bswnh7ni$xA2A$%7;^V6yRxGXGEItCe`b2&ljzwsKRHV59E=q*F z8WBpb!NHFh5NzK$-y)`){zPp$wo86{ahB_I1>2Q3U=$KQB^4wj{OS-qAz2KQLyud?5( z0?}>p5B|8E7Bn_*)>;ls-V>ob+n5tth!1{M07}1JH2s>QNUZH4?g*B?2x4XK>e>-Xt zsI_yIY)~CDse^Mt-iFrX7twre5EQ>AxSjmy-VWDSc@k%9VP!1>lc_84z1#RKc!o>n zck$YBdJ6Qs&wcUtSaYYY1iPi*c{74>RVCbk$A!dnJIOXoGI3-NHiXg^xHY|SMT2-g z)5~Wqy(=ihtK9OoJt(MMr()T7ff>h&LZ&loef_5le)mYSL_-8!2|KtQNGJQ(>^;BNCS6NBeJZq&RA)2q=_YIb>FGz@ zOW$!G+SQxW zxD+HrqLuf1Cx>f@lR|#lye(IN`~8Tt)VJDu+mvhAdmg4t=IE!bVZ-l%!0{Bg%NJ?N z)#rYuVp%dnZRa6$Io%3&US!xP@X4afiVH?Xes^ieVbp*R4WaXS+71$N-t2MRX{t9d z2Gs!$7~89U6&Y8ri3Hy#3Gnp-j8J#2Gj}!s9EA%PZ?B%fU89tBlOVr*`Z9~Cr`gV{dl*!w(_TOMv+-k~Y zL7&ey>}>URSI5qP((-LsnAgvNJwItB*tU;-`eA~&9(Ct(>i@nB#Cg^pa-^k{FV)v= z{n)*rgZW64#}Y;_z2Kn_30CtdgE137dIhH-LK{_0x&Ph5hrTuZVU|Mv555#>?!9s5 z>RXKIzEoWvD5sNNpw?pi5DO40YB7@Gx^Hmhg$*^f|)j5WyUU4}7=h872xJZ5?@k7*Db-73LQ@XlK_2CnPq`SRc( zsGn^_%WS_r>lZAMvV?$ zwz`}4^zQ3c^0BO;{9l@(Dpcoh3w{bKi5B-vA#A0`tQ&d5_C2-_suFVS9~t&sDllNn zYk%sJc&t}(vAy!&Fw%uS&hC3Ed(dB`Zdc|&ncS-ewy#$*Q#zxYJMh%9oh~9zu*Gz z>Io;VrT;d5SdR6-nknXqPuk90+ljo z?7?7{3w}@|iyNu4x)MY+(RaUMi_TkqnaR{wWvu%XAkI2bc9>)I8=c=wJ>G1&q^K7U2Eo$nsM{*@mzghu25|3h69_HEdm97O@M$BxW9V z>fGk9NY!DkI_j~O!B9>nLmrd>glnw?(v~5InBT>x)G^ywgk}sGOOlhkm1`eo%TuMy zb&QOp)`uhdjJ3Z8q{BZ9kO0mal2OC-E(G%iPlVf= zR~C7VZRzL!O#G}#(--C$lGznBxQZ@NX2o`Ky5}Uk4upz3Up-30BJYg*2%*ppn{~R& zJQa=1M7VpggHqM~)>;&b!9uuI9+dyssQ#gM$ z0K)t%sSsH|Ex{#;Zo`zTtPE(N9^bPYm(UC?Yot-?63BU%p8HvSF(=zM=E;IKubbc% zKo;47-ef}m>!39*n06TTZL8LWJTJpe9KFSt z0x#FgyNivz{(Kh-KDiz{gj4$UQfC4I{!ceZ>XqjLh6O*bDye$GTS|1EyAzfbma7q& zaQ?nuafSlhuih_V_k)-X6_9OZhc5l6GY4tnng)gXrswKpB^o}f!drVMw^vuP%jkyn zIh}{a_d87&dRyrX87#vi%F6q*a>QUb>zzhsb+A4OY(ntDLcG>EAUh^LTM=|!X|$U2j{%` z`|Y3i2^|=o6RHpK&!7i&)2a=Z5AC(P3CT))S77)akPf&UmW0`3nd9LHQ!}WH`w%eR7sKjAdBoZ5VueB}M#*6T1RZ`n68Y zWGD?kee)E;a~2)z+C-*NovIaP))@e(!X>fH>pPTT{!Z%M_^g^|Qg!b$R9AlPS#i(b zg!@p+sBs${5_A&Yo*5igq~(Ji*-|}2y6`Q~ld#|m1i0$%I)URG;r}Ki{4D>?z6!W;39-Xz*t=GMK-QvS zz1~cBzTptt+H(Y`gzNyNY>`9Z3QCOQ@ClPTKU}^F zY$IddgIH=-zlsuM{@H`x4o+6lR8VE`QLk-zq%VjWZ@lw0g`CV4NBwoE+T!;#gvF{~ zwJwnq5(jMR@>0Pltxb4f0QF{_Al5hU>9W+f8(ky31F+L-!!Cpo+U(!14%&an2D1X) zDqKXEP%~^{m*n%Ko+g7CzC1g{S|-g7%B6N5tTGqJ?oFbOTTjQusrzM}ti1f)1n_QAAIByH8+dS>D)9!o*8 z6xX6kXrcmU+_)?r;j#_sE9pya!-^5Q8rqbmuBz6r`Qg4Rl$WRD@gcM|u5xcn3*Qd{ zENP!#9BaMVecbbasE;FQ&D3+TwrD7H*V+hc)&IT5j{LvvIp9lu0hqOokMi%O&FQjJ zgY~OHmj+E4OfasRn!P^r`O5#z-)A*Ir|mfQZkI^mH)WBWAHGo4&i8!%=g(LlZm+() zRBJ)gBqp_@ZAQR=yRQ|@2w~`&cqvX3{Vfc?PLMnV_C@qBxS;N-}DZcK0p|P~u$`YXVWrm#!zNrAFwr zy>i%6L72np~8tru5mo}Wp`lyq> z3n_JJ#b_s7%y_d$Mf?<072{A<(fneF4l`ZlEcr76Z1IU%X2nI-u1yLUm4mqlpkodq z_;jxyLMq!Pd___Ht|_587A)=}H|M}j|35QlQ&wDVSg1YllF)x4St&`ZHWtC#vDJ$1 zysg*Q)#{&h9*WEQCO;)X$D0u=+^<^`Mnfd^mpXOJqP=cCq!fiE+y#9mv);Np6Wzb_ z>Bkm}Tidw;mtK%8>bst1Xo0Xx-X@{1)T_s~4K+5|yL0RQv|o!Fq5b(oQYr68 zDaKB7WniTCT`U1?;iS&&i0&rZ^b8#wGhH0;P(tX#BT!2X{ikG#Bf4RB(jL0Equ^Y( zVf?QHPVAYDcGTO?7g0O z&N;6~3|b@HGXeccSDK7tvcUYN5gz}Zp@65?;MwES9~lVc+6KFO2jm{{Rq2)SW5*^$ z#m8op$%Oh^fU1;VnZafpSz&je&#%>+R5qdUn6uiS#5zRN|+_&^kb5%nx09@&mu&@?pD-@6j|T9p?3o&Fx*(tlQHMu2iHY;($BNxUNBTSd?Fqg=ZD#Xg;7-}uPJHs+d?rC#gW=0&Z6 zbx+q@0q46fg~+>o=5U0pI(;6b8xcy&=Rp*@TG(z)LDvjpT%=UvWTY`6e)y@sDs}8q zoFrO?C&EhhyF{*f=lpAQEQRRnUH)N3sApS8iaIxvZ+ql5zNyD5WGb6Zz7B`&( zbMT44!7&$b2aSOMGf3l==LQU3?AZ-u3a}=;9ng_uO;=|6#>cjD%_PDC(9N}C%ibPb z7UEkCJnDJ>eY1A}SdK1#OD%|$vIjV=O9gbtRQ7#}+7T2>XJ5NjT z$c{ZRR9kC3wirIV8J9V3j9;#pM5PmVFiv8;TX_yZAs-^%B;~1pF3i}Hg_x^W7<>>y z%&6864yEn#{AAX46}_(U-8)XZ=)@)9=GpSaNaPDWR2otFUjLULqJ|taP@mtn5rPy? zhjT?W_q9U6BLlqN$Il-C1r$UC@b{_9`p@(*=+dvsOpO^9@|^=QXM5@+SjG7>TV4(z z?{js!ZqH5ia+p1A{`Z6^~*W7`#99gA0-Re$PE*=8S zh0yI@uAJq&Q}Y+9muAK~8YQ2LV0F~r>c4FvK1XKG7}jSyJAh@U4}uV3zUYdU?tpgV zP-H7@BJXS*cU3O5o(CX_A#GonXD+1)a?X=1xiMH_nyL?8M6T5$7hx*@&a-H29!rxM zT4mfwL)cGsk_Z;^BcT5M)MSxRUzOj>a|I6X?v23bXC)KKqZFTArq zhCN@d#ZPc3r3z(m;khS6;QY+5N&%bJc zcj$sZJ)n2bXsChjbKvMACHT3(=zj+s1Q_+K-7&x0uI&4rZr|=-=ju3w@YODp z1CdJ?eY<-s2nO}Tx!Kak>#t71J-&{{Zx}D^tje=rzP?_S<+GJkT<_!qAI8qFvwY9q z-e}WTg6sWEtTyW-)U#q6`4<|y>^545g6VESx1icgsxa+nd6bFnp*-vT#T#02q5hs0 z+|#m^t*P4y@eot|PXLzqZ8;<-ls*2K61mGJxfW!+urhnY@<>nhaow{Gc>=A5SPGn| zzh=z66)-56-~+Is;6HoK5IE-dm4hDk)%WP7_FS`8`!7!i9*%cbN)l@FHmGpCRs`?Q zrGCJr$Ei` zI0zz&`I{|#SaV+QZnuOdtSB9g{G&p=CbVY{k;dOFWrs+phe@|h(}g5q)#MEB6Hh}l zLzVEAMaYWMzWvX}67Wc%PCUEp|2`5h`KtDxpfB~C;34tTU}CD^yCb7ygjRUCnHGEW z+etwy)PN@MV)Blu^rER#cO4DG*R2@z%=bhSG2DJ6-j89*Ji3+H=R$24lE*%83ugP; zDA16tG}o!!qZzTj9HF&Kfjz;rF{m;;y3*$_g&;dA$!P21;VV}4rL(l7m2kqVp6D;w zRJ;%z#H8c4He$aQ{r9NXM;hP^zlc{}bFZmWt#(k)drXGvB1LQP6$BL3Zk7Fa0_jJ( z*BSmCRGTvWA6qnbk=lOI7~kQla%7CV`Dw1B-i#6&7kIF?~ zvOKRWuxuKiDd)5o*lXpaq4mJ}BMkjQ*<>GzBfA=#g4&+DBTR_t6Vm(=FAK-~P14{m zz=Lb19daOsHtjepgz0emE1g|aaKk($8d1Y%vySY{c_iTp(KfEU?~F%AWMB{O`G`vq z@)lRfeP!X&cmA}4(DleAhfDMW01 zxVD9(94~sk_XD>Y7%MY_>i${?zIpASZ)76)N1zq?Vl2{6dnPXUA3%k0vrO_DbozqN zBTj}TkY&n{d&U@I`E8a1-c%65P1JR*eiNmMSasWf30I^WA#dojey9|{3*av}^y&Tx zH{XJ??-#%ePZB#M{@P4uDt$kdfzb;&B4L#}-EWoCP9G42RVpQZy`svlU$8YQ19K7w zh?!W3(2vc3VpO;io3HD6{Dx7&;SWyy7i$>junR=m{j!o!b6W#`kx6uZq4Wafx>R+j z-PvD=vJ{6D4sQ`V=8G*7{55S)5FNC*Ym8uZy>U`=AE$?Jo&ZV3Y6;g|&C9AAt>2Udy z3K?5@7Z~MpfhbS`W2q>S6FR7KG)>&5eX@c6#W^EfWqom=KxR{wSuWpnj0+16r4ALD zF^Rv_lo@B7I>6QeZY_!SfcZM`aKCpk&P!VQq|3@Khg_6Ynz3JgRP3}`>#9rs84~~h zbPu}h;A$Vb2OdeN;&LK~P_idV#a31Zec1ENlpIGdYiCl{v9Xr8pk0HeJjs9otE@|o zf}p@mgYt#A8*|IzcZA)P z>{YYESnQM!x3CZo=caiPb($$>K8M>jAfjzu0|UC_qW=?)6Tuf5Fz2KZJ(NSCqs((QyK+|9s#^cBD%D<~2QN`s zlk6j#7KQDB))~Y|i zb6t3;MX8h+mQ*`Tq&2!Pf*aGFF~p=+gpdgDo^pC9nwOmX0~r9(F>C9|w(105IoZQ+ zn$;I%zob?UxodNEbZOI}*|#&>sL8`*?!FT(&9@B1h5aoONd}(7Sb+sH9C8dV@Vof| zX*z)4fQu9s1_`c%cGw7^65<>P`I&yLK%shJz{S(G()Ko!dWt36)Pzt zk6OAA8tHcp>an_gtqGx_2a=Kc#hEsBkzNSU%G)P^zCOn#LV{Nd%7>68eJQK+%oj z_~1tM6V&j(>$4^WjQTBq`q*rqP-dAD=X#z~^wP3^405&V3WUM^djfR-g0a7rRl;K7 zX4`v_&CU5~$w1S~tL>B89?xnJmx`#RAI!?kh@ii_N1bC|E@zj;;>l3(8U5{{-VXD< zwo#B%U&O)F-C!(?oi0Yz7%!DF4{;PZrImr+r%yl5JY1G4*>v%yY=#iOeEh|m*>jB* zb`lA#f%yP9dyG&C+b30R+(0E?R8-yAKmGBH>>bW4)$F)uI!&d$6!T{eE8_L6Rnwd6Wi7v8^#7;_{qc<~23 zodVgrh&qG7K8aBuJGH-N2l4TJk6DF;2<21}A6~DO5f^^cdhGf49*fGn1}W0noZS!S! zN2|?1t_SK-B!U|aYY7YuGa!xZ!r_F^+%*lQee;uc$`;Lz;nn|^4KX$GdH;QN;L9X< zNJ$j-j|vnifeD{6`0UAJ-1~m!v&R(`1r8Fa&l8f$y}PROpQ>2z`G_TS11D=|=8LU; z{`!;#XPAif)JVGY4T8qJ&9Q-XCVhR>qXZzhk%`iW(_gku$q{^2Wo>KSs>gNiSMUP% zt9uH@p(uQ0hud%u)6F>jlqx#AU+7a*;bGIpRnM6OHMbPTP4&<(D$N6KJ)-FrC(gxZ zeg{(U;psPrp>~83%9+>E;91^`K1qy=GN*i#v8(q#kWDh?ERfK|Ti*nNbi1%W{rD#e zy3k^S)g&{DNN`INDq)6+ugSKl(4Omakjq!c;a1a~qoKbGT4dG?pzqFK((#Q2D{hu&f{nHt7AM*8~T zP+J}vuzhn)cXzJ5ySon)HHeL+QS$^+hCuq<>@T*J{e%u7zh(m#IKdt*i+_tf5W#E8 zz}rZuF@)mIjZYkBCuFn)Jv2-tBgIUKwY5uw8yON(csa9CjR`4uV0_Ar>2vNGkwi!jdBvc85GvUt#D9(a6Ab3*Hw^jjmgrLF z#%6$#FarZHHcJlb7tela%{aBMdS?z^Q|Ek5)%mn^r>BV>p;+OhN>+cqez*73cf>aN zCvW7{;w2@j;KiIQ!75~SC;^QX_i9BN-}cdgFmQFoGw>2A>giGzC9!kO~34}rFxy?_|MrQ);N>0qufo{eNYzdGsyCF((xY^(ugNt(+57T=egy& zF3+tpZ=3hM5z*3zqlyoA@}GOAQLKIhXWEe})Q&f?g-K38%&&W=0td=fSG$yxGEHK{ zR=j>tNX?Ya^RbnKr8fQNB3rL+7J*H}(dv;j8X+@rE2Q)(-(wf{ujFDF{g9~t1u5ql zJh?kq1@X-pGg$JL1ahGpc+>rYXLG%nj+Q_58#yPbAhp95AGhe!?Wkz3UAY|D-0%DF z8sp5Q^(o;7I*400ed{D&bH4sl+KbAvNbJ7sjGcM!TIB&BLSz>e#jSvwsxp(41g*{x zn&$hH+>!N8CRYPerzOUIXHoAqrJEYc)$S-eVhOxmXnjyy7TO*ifu)KusIY2h*jA{< zGn-{TdnOD%{LoPok*X4z8+-f@Wdo8FcHJIz)Hv1HAZK1O1S_CbteS}hzBdYcx~SL~ zx$^I_b}p<#b?ww=*xG8fH&uAZM6wKIh!e;a=naT4t~a1-tF4szp+3F}}#zH1qoK~cct)dU&M(%1W)j)*1&{HOBk`%|CK|(*Wh<=V4xuWnY*b(IKF(N zMyQ2d%x|o*ve$>zF9H|6AQw1(M92+@dC1joThLb!(M>W`bc{LOr~0ej0q;=eFq9jf ziS$MJ-myXaGpCND=xl8j51+7-NWbatM2GR%v$Btzoxl&5mL3c2d9c#Vm%Eh<}%!jvgs%V-NtR`#9+Am>cl28t4_tm(6&e!+qDCG^v2x zlA~X)Rj4;)ASrP_WQFDK|DmFDo6T6u`gyxB_Y!XOFF5aB)n2g~<}z(AZeCV~$5PL_ zvk~e2>T5;x@Y31&nG~qo4dDnW#t*?QW()ogY(U5)e}D$`qQLo4*7Q1`9E1l(PJsH8 zbkpQ7I+{ihvnA;J0tGTrVb8@$7rnnonT+f{Qt zFmp}ju`)U%?H0vcLiOSsE$=jx_*W`woGf;mL92bEl^iH*qG*(;dDyCL6oA8*+o|oF zl(x7^`%;%53d)^b^>L{ha(_INtRJ{DZ+;{niUM4DjbVfUQr2aaL*12y7#zAcldP~j zvXv@4)r4FozcPhAV?wr&9iY^p^W|l(%69c3INE}>~D#{ZL`vf`~Hrl#>#*}T)x7NJJxui>?g{E$qJM#p+nMiuz!c}ET7<_^ovZ|0O21Z%W$@`;cH{eZMwMHT2-g&pO1q$;#-1*R?g3x2pb ztBiVGbpa1Al_=j&<5W`dpaZi$fkxr`n89l$EsDA_I8e{L7d}++SxR&aiD)Sa#6nhb zBzJZG$MTX+E&&cZ%CA3PJr9ybwBAFaXpZl_B`1V8C6{}?_<=Doj=}(E{NoNK4A3x3 ze^WIk{!)3Vac9)Fc4z++x9m$>Sjj9TpDvm@ZP>TR{$4b;zcfT^cMt{XyBG`K{kP0Z z==nq>^C6-$v}Kwl+1d;%IUtN(H#nt3Gez=psw97%=ZqV9uGYdPT52bemIMqTK+8R4 zbtl>W*i8G}=CUt6}#6Wpsw_4p0 z6V^H)8m@!Ihk8y)I1(PRCkBoS!r{*-^6;+*J`z^7d?Fj^+#=5$h@1**Uu}Oc+aRQX z=aLR@#;M!+s7^YF8D$4Q;`vza7-~gIO&P1udZ(-4jqJLl7ir6%nvBY~km%q_NFQt1 zsD4R~Q=Sw zr9R5yC1TC(G$iy>iI>>I{HN(IoUN<9iVEs5pp{=WKheptbFI*|09 zq&<6ueIDZV@20Pj6jr?{)ch)0{Row`*yKg84I^QjZmBG4jdXUK>bpi!t;#@@H0Q{ZeI+as^PBKgDUeu`g_6^{?y_6)$PmVU0fszGr#3@K?Y<$fL^3G@P^+ z{Rp9qPHAdcVX=UKZTNO7he&xoojdx{NYKm56<;;Wyir|VN5jL%2O%Ve4jm5N2e}Ec z0Ui2Zg}hC(m9&IaD95NQ4|GBbaLj!LUNFr>5+#+xs1)VsLa2V!z5Fc}!M)lo=1GXJ z9a1s8RO}SWc*I5-GnjUn3Ryq(ppA`)YM8fmcuMu=-9jlp2*x;Hv({fW$jw^dI&F0=FV6vV$S%cr*T!Xi$3PPirQTA zr(;F;EVvsGfXgm#D!dVJCb$(4>F>U`eqF#(L~@fxN_*!k-b`_)K}IrIdI=7e=M~+$ zlqwsW_j}ovrt7*#lCGk%v@hpIx&w`T7w7nDcZtBZC+lO`o#l5sW138pSA0YcJhwo-`v8o(a%b!ojg$WVhap$d4Zak2w}J-aEH`nrNni-zQI|E(zAKu~K}Gu4zf zBQUvSA;hDueA`Fgbl@97bKGIvLX6$$UYsDm4?hn-04IP4fG2<*;3_~g-N(Ca*I$#6K}i=7S+7gC z^t*@-usSRqE=5pcOX{@(x?NW@9v>X9o8lPx>CX4>%$nqZ@+Au{=BatxWk$T&^CF^3 zx)P-EjFnpjJ_IMAPAMtab^t<(H|;o*R2aY}2_uZ%a}tbHKp~~VKb?P6p7mM$TF!W| zttAEX+>eb*shADwK@JoYHBjSX#seqobIUL%--b4eme`g>mb9&Y?%^?(TU$66f-O!+ z4wMb?Zci1*A_>}BoK3HJWvL4*jF>kz%-ZYOF<}zOfr`JQVHb|u`h(KL{;`LXN^^`# zEyxolZw+BG>RALG6V3*`cqJr?5U0`M2Y7k_Cpr+KNg4*`CmtQ(yrKeR?NSHPL8*?@ z;pRY%pVg%y%mk`oL?1sFn3Y-TmES5seVhV`7_nI#=JA&`!Gtj&|HT(X&>Ic>{IwAy z{Z=}5sih`l4bwlf-@F{-MX^ob_wUH<;_-(tDLe6BR3LcTqmSaQ1cgt>G|GtCR zbWM`(!>`G<(|U063@g_Qi2}X)Y5V>4DrQsS>McYnq3I+gS=ZgE_ouGTJ>4iApM$|c z#kN71VYJui^d57VzR@5e^~saCsq`hE&7w=hS0?MTZOY0CK0XoHmP^I zDhg{`7#P(?0iCWi$z_IxPYQ{6>zg%zZ1z6i7R0#j3@huQu36-&fx|235O|c10O39)Qim+d&6uqi#)}kcG4=A6G0yoRKtLxyr@GR;AX*df7^oJc7O_lR++txmW^!FA!_4OrY`wUPKuchJCg@HVTwo;oajh|SfEs|CP%GSl&?Qn4gxghw(wJl~J5oR+Ji&4p? z5C+?xylf)@GwP@>4-^B!hIZvI8gVSWF4z*>*&6U|s|3CH{htW34shj)7kzwCer00G zMC_^<*v8q%aMI6~)d!Lgrq8J+T7oy5%i zS!uZ02QnaIzW3z=Dp}z> z?dv`6>}<&fGsf~fMS=m&1Jzf04W zkGH2hJFh6bl)*n)!5Zjk=5sm9mM(ci3UHvr=&CBU;JbLm+1FvAxW8VPa>pee7!m@E z_!lHp*@$$L{i(NMA)QN`fUo8Xf+Cc?U=!`hU8$G)#)rzvz8LutPDIGR_jXhRT=ULQ z4!ek>JRXX16MKQQF_ein;s4;Ar>|EPTh|C5`1*-jC)01=b~0qaQpIh8fe>s*j0F0V z7|5fIo1xR70sLPI;eXZZf6z~Dq)pPQ@vWx-+Js}srdXdzSUqmG(>qU39Mv6IxdKlU z7s6IIiYhr-PBow;pUpZmkCXQuGADF%4~O5$e6dNIDNimRaz0qa_SWs2Zk=8i+ZUxv z_)yn7`kKgZ!{hBGw1jr?jcJ%!XGhF3&Pd{^(qC}FD512u9CyXNWedh2rmQi`@AoR# z8CLbv2l+aMtM9vak?Cg@n}J5b(HK+2Tk{Oo7t*J~hVi=8ityS1ipt{`M2eaiJL1#> z)vBGMlqHCa3m{dHTPF&(vEr30P1466)wNey8DW_S1+7TYJs3QR#L4|YqHRs29+I>k zTT(q?aC+q6VZULaz`4KwS}4=!odc`B0>s25T5WNMn~XT`Z9u`$gx%MyLLT%U6`wVr zmcR#$n@T;HB;IF`LQa^1;+8ml!g}o$QpeXv>5Q zq+RAwgf5)ZRIKq7Mb_n5E;R`UwnH>otKhR{&{V?;P)Mk-oK%0mwE~0(efQ&+;n${6 zkk($k40Mce3G1m~lK!#_n(jLxZmARgtXf$5zRXZHW_37{KTY@1t=et@i;n^7e0qTw z(<>>RUpjmYMBt^#*j1XUjJSMj&>kX8^AUXGlx zdX;`RuRkEUdLyt+W~TE@>M;XPsY&@WTEdMe0Tz!%mXfs-$YOLDIgPjb36H@ZArFVv zGy<8VHaa0^GHcX89le`*5g@o@XH>CT&a}L1Wau-0R8bII0gF+p%S7t7Th}LmOZA&f zNui4OQ)7GnPE#{wr@>_5@b>Pf2i7b0-*D^qHilUEqkF4j)?Z)(O{U7>-hrHDinQfR zr1XLo@np;X3Cm#U5C6iEvtx8kGhS+6nCATyAv4PxL7n}K+3(_kErF}tbkzYl(N-IO z36<$>kl(5_Td|mX)hjDY@X4Xu2+U`Pq&_x55@InyMX>;535>~)qtrJMhnXCj2FT3v zFPj2J$t?d|_yPk(uMbLi4B-^}A+iS+9I|ch58no!buF2Pa~(F#9whSj&1AD88!BAB~W~{H9sMMd;Uy)!<5AtGE~_sjgdN6UQ75* zuN_n{%FhNVH+qHGY8j8R+c3|U~UkOI7;5^w{y`;SL5#pW)T|}YfrjJo^t-qYegULp4a_}x`O<^{@>IN(V7>FE@g;B0drKm`)>io4}OUEli z{-+rQYcwe?oCfGw()FoIl#FB^lsIN0?}%zv!s14agYxcxdc61?ejk1=ettOk5Q=;~ zKUgMFA{KIO_O2|}XW;196DYsAri-6B=wH+Q7N-%d661#_+%G{r6ekzffqFgsFmpL~u@V0GtpXALo>94Ff<+Yx zPnrv>E;<}H(S21PM0*aQ&v%{j`pf>C^=Sm$8?h%3ZP~~?8+GXKFr_P1FgB(~e8A3k zx1ico2bn^1uc(R+=i^_h>T5(}?_m*JN)qK0I_z_|${H2ATt0K?>IAT87ZesGDJYBV z7vw_tXv+3>F>M^MI}&MZJF(;4xAhuj#a|AJY?;aIQvSIf&evrv_gg8uaNE`jMr9!h zR4k(nN^Gj?g1e3tFa_x#O>p)@tPnaNC?tgFP|E=S98ZzuMM4_n;-aD0`KlzI)}w0> z3f9n>a^exQ+YI6VZ3;P^!=$4X7T@aytS`NN)u7(9mR)N}=S0=E|Go^pc=UJxxU&er zg@Bl}f-7xp}=&0pm?&uSCbtUA2F=lNjz>sf3##B7+LUQpM3 zT}n@EXOo5{r~IUp8qN|4rubz_=M1@Bp?99wJRkc$p^=hqP0iqvz^%)`oBlSA95ceM zRu5i;!g^A9R?wkWq4S&3#ZuK|{RpbA`PvYnc({B92%Yv|n1VXnY@D+$Yrl6O5oHt?JYI4ZFNK9^y1!;$YZp5Zaf~sL{Zn=}?Bt{ps^>pR0QBX#?5cI0n?$+9VDXI9`1}Ag*H1QmK2-m56&;*#jK7_sBU92J{T{QCY;DrNnu85tmd=Y;%dojz>c1p`b~$l4NfEe zvLW%k-Av6pdwd)VpAGxguT?@Z6B-R1oONY9xoaKHMjzpA!z-!BQEUa7_tBZiM&9f}Ri zyb@F#JFSc_M2I+7RA_QRdO4jaEK*0QY=qc@t?S6q4r`H#A3>9|JIE*HO_Tc>M|+k( z6SlX^vXekIM~-S{@4xSn2!;o%@;!llh^g3*bs)avJr|JIMix;BX-f~1+`)tb;t(*K1IDDd@p04sW zm2@woTj!ny?rg{%@V4?W;+<*HoCBiY6aNSgsq>r0&muOY35BGfM)SCO_GK(bTSTKh z30dLa>s2xEv#bTe+lf$>I71pLA(;=b%tHpR>t(MriSqJ{==Pb1D56ebl|R^HJ()VN zvE?*0l`vSQ4r}VIP71>X?_)i9l*K*PFuVXB?R2ncV!fT0{~%nPF*vE;1*nIhKVBYh zJdWAyZ~q7H_}|g(%Ho>h1<&#Ti!QP7FnLbNE6;Z@m3^d*&cICewyM(~Y{G?ZxO{QD zFwH1>WH-~suk39zF6iJ&LcScO^{^N3>_u*g_i<|ueK#u}mj2MlUf=BeZ-@-DP*oTu z9+SL=_y_X`=!Q;%m72z@~Y1-;4mP`|E0AQxy zO+0~sQC$KV^=X=en>k8G@Hk-QvXVeJ1sK3onV8@XpRi_wO2BicCn1Locid;oVJ$ZU z=F_uKFmt}N|M}U{)q4A8TO{+=l4)-~5}>aW!)KJITdL$#r2Zt;pw}h9?E%E3q^r@H z&M%(RprC$eyHzypx#-=qJ?^Vo3Vsdv`1|y|0ARgN0HIAHsJ|*~p2Bj9tLDP4$^cJ` zZH>z$o^Lj_oOEaB&G{Dc3mcv)yStbt39pr5G)BeX%xOjS=Sgs54_1gav|#cqVT zcm-Rhy~m85^pnnUjP8)V0yU849CPGtDatHZHK6vi|zz%B~>i(%R+my_3+Y)+q~P_j#s05qSTrZzH@nU z;{zsj3P2|za6;q(7I-fVDN`+~0ZRw4joMP+9{|Ytkg~bZG6rmJ1=IWR=pH47ll!rf z6QMQ<3CwU;KdRaNi_;z3`~tbJ=BA_p)<30}U%9-ycy6!&egIg2v%dq~_n)21xj>Oc zG%^0x#FVr_6mmv!`rwG4b&V3J157gszF;`fJYcJnceYRmW$?`IIqhH2>*eyo`{m6{ z)10nP{Q+x3T7{%vc=Y4xw8VCugi4_ja+yq*Z#isJ+(n)%5l7UgfucL-fPR0mV>!k; z$UXSNs%Z;y>Xi@ycCicR_4D@s&zk2>%#mrG*|G-X+1i( zf!<*~*oUosO-qxbgQl$969c(U*mQshzNJ*#WNq@=)R^X!`&TRYk+hF2~=;eHr(U1iKY8pj1PmsOuQ%p@wXNSe#zMPPe(HeTCI6?17Z}_A zdu-dfeAa1F6MLN0>H447K(CA(#X-}1Ar6|>i$zvm%|R8eNAje8-qP!Yz{JAExi#yQ z%Hj#SLcFy&F9kQwhl!!a(O93=Wl~i>)-a_#x@2kvTReo4)~Z-&ECyN<|6f<0rhgmT zXhqpuu$b)`&pA9e_tam{@hamSB$h3N<$j0S8Wo7*^KWxj@lbnz>9H;@-kA_fj3O5}}D_2_NveGtbltb#{80^Xdoawnsk}No` zXItl3aKaUnzkUhHPJYjRwYa?qK>P^BzP`u5vQ5-tox_2`QF(>`U*Ou3-c{t+e6HXF zHBt9XLDkgjDN@v$VVN#L^*19DH1n!H*7!<&vZlI~!RV~D1;U>e)ggI##Q?2cZPWm;KP*Y-+{IAcOx_ zFe0P05ZgU*!=Q}Ifb%p^4S!1M@F^`Zoy)i#d>N-7I0lIvb~#nX2a7e|iciAuYpa2} zrKQ_-#RGF74K8Dg!>8(C1KUF_|$M&?lkhbl*VNi%cF(+ zgrG2(L4mWcs%5tkd&Pd+jbV<^)Utw{b~0E7%_Vu3s^L@jT9e)=5!Zza7vZb4p=?1$ z%~o%%A}bjjidwxHdI~W6DV4pw!D4^@Y~f?=^5pE*0{}OJ_50*E10Mv$)cuX&T`Pe4 z_gmqbpw2BumgMbn%c}tY`nXU~gm@$ex=Z;j+5^25;&Grl+B3N;2jrApU`wIdm#i*p zU0C>TPnNI8<{V|zEose~On=F%6+UmpiVAF8(30HFumiiSQn}F_EHmceM(%FN#o8fG z>6Xdx449ltEz_VJtn5^T?UEk_`r{WdI?KjIjZ?!a9Fr;j0Vd%oBxi1$H`+trhk>KV zO?PdV3}xO?m!?5P27dLj{qxOQp_r87FE4Wk#wuo}2pDJ==YOuA3d=zgZqRKKE#Usl zICY~+o1NZq;O(=y>2KNZE;dBNr(({~m_D-iPCXK>bVbq??AYe=(Gbf@ z-KnLTy9G+=|70(?Me;b#|FB~Q{HdjAP`^Pdgb<@5=p8gyH;dJ5SLaCLl!eM8+!hs> zkiJ~swQRAsUMuiPM+%=iCOGNou8|wZ&PlPNa}rH@uw)n56x;-@o#z49A zlW~0rWt&lEyBT0IZS!v?7NqtSsTaB$+^4G-Y4~fb-W}QE6>iN7BUn@XOT-B;5{h{? zj`sy0Mbi-Y0re~TG~_ViGZ}@n>T!Qn%}n*X2;lRT6Tg3Al4s44z6CLtP5Z4Axz%GP zhyqO;+(1z^Y#}qwRC8g3eIfILAT$EHeS3Od5QYB7q=@yutHWVvn6QGsoB3iQ|M_D5 z=}UDprfI_tvK*evG}i2Nr0<+HYni@o-mAWs6iSZdTXY|?ZQ#Fhb?^c(qm?so79EFb zsGw9dAJ4{dxRbr%$Z%_*&Y{tEUV@#7r`~A4Y~FotUVhrgpLDC)>KAq)_wu;O^98VN zJeXr&xaaxm9=}EX$WE6&atQK%N8sN~e-7rO5G_x1Fwq~HrHNR_1qM))zFLO8-pAMF z5}`bL7T>rwF^_*rLupZ|Tr9VXQ>U&QUU7B7ST<3)_@t%NuivF0%^o~o-IQejkPt9Y zgA)Pu*?;}aNGJh&zm!|bUl+a94j=m6efrjNZD&X3`F-CEwzAPF!B1uc-a4Q5sfO>> zRm#|2!Ochw+}BR>YmVI0Vh^1%I0`3R$>GSChiq5$67!I34?2Z$!%RKLW9Cd*!1~+H z8vAm>d(HLXH{au(yyX1Xz_J^1%+FOjm;+c@4`1jt{I)MVdtr+E&`C?A@^uSlB8ytb zz800qw_ybUHOPyvM8$$FRUz2Bj};}9IBPtCSL(us&_7+W+ZAB+8V3;1=TwOG0Nae%K~jJ1n#S&8^U$t$0+RaC5+G*j|*mqh}i|GE0<4Ut}4Y_CYf|D-xn}aQQRi5U5)yaE&R7-AK zW?ykaa{lJfS&s21rwh_?rxj)1248y|!{3R+GEPa>=@_3iVUJK&G*d@RX$~7{8cKQX zmC}7IfC&F$i39$#&LV?>|GtF3+qJ(~VgbZBAyNcrVi}J5H4;mot;L8XT8$+H&YyyR zZ>|-GkCY{e&7MbRMwVwYi!Z(^u@4;^Zf{ssU4sPDcXF~{TFB=o)qUOq-RAD%Y{lj zWNea={0J}R>@#$Z!A34l^!jDz8RA{OY-Gw2m<@$0>Mzjt8A2awN<`OGa_fOLHwR}e z8LVEThjYr~tU5@o0h}45f>-%r`aG>*GMx<=F+SRw4re~+og`Asf9p%oRa4&pgRzCs z0t=#zb80-HGVnX6I^IH24ja|^(jroIZ?r zf`&$P9@_iYO#cE|xV_4tx#gcP>ukVfcwx-?W|w5)Oh4ToFJ3c~3>D|MsE)2f=JAr5 zIIY`Jm357U<%WD$5|2Hm);?=S$>9%(twc_b1q%kz_e+BM<%Xbw^$U>85hFwV>(5A> zvApm&evnIZP@uGAid5le>)88@&dGn{a~WK;L73gY&29La62^+fM14{&?P4|?q|ov6 zMB2*{{zkDp(2oZ;HzVn+?l|^JZdq`e-#HHR(KO=Wf7azXQ+kuW4*uAI|G>l=44rvX z#PfP&Akw;<8lyomO)MSt*Dm2F%;fqs>F8#HnB(4=FeMv;QQB`GDmPHp;W z_R;05Qq}(T!qA6LAMoUVT7MfM-_{jXJDpABepGkZ10m&p#ry`mkMvqGlM?^8t@OJ| z_WL0y0KfP50g*uZ3F*V4Xs0=kO_8gYb{_Uf*-?qBod<7}1NE62OJbEzYQI7szJIXz zzOugXuWjzrJgLDiuJhqKVW>Ac=MR>)!+CBl-nCr(`*zy5`6E?L6Qq@b7Q~4!CQ)$0 zrU!`C=MAE!z3azG(No>rdP?U>j=r#7gv)k7?!SGlPG_kP7aiFF5CDi&=-}SpA@b66 zhCM%EI~8KHmaN5RyLX-+QQz*fpL|t+sEaOR+F0K zjy?7`k=@Yok#*VKWp2?`CLed(wx|v)U4HlQkZ2pN`o2fhM`s{Xe>H-b-rXJ*bEf%7 zZXyb_vz2{kGzfw2bjVZt{w(~UoctNNL;$NW>tyT;==3LqqYvEkN_ zXa1+DrZ16UgMe3g#8*zuT#hs9cxfTo6j~FMQD~bDF*YqZn4_cztrD43V!2p z@G9QCAd`U|F~36k3W(iWzVBdWw@nt@xiF0mJ`w_AJn-cgPW$~~kjI}&FY__ zLBAV#Hk2 zWo#tv+@&jlPiGI^D#yG9MRWSmlu|k}#9i}(zU-P}55HPKa7nA^;X=fl)WQ3l2_Hp2JLrFtAAi&J2qFRA+LM~>{^NGE|Q^$4`ELf{7-jz zkTlx=gwjpk*V2Ge`PnV+i8nA;k>=W!)`B6zb$AOsjp=cQn@Gr z%c1UIe@G{{UUYx5z4^XiW-`$yhrWG6`KPUD+fyytl>c!#7Y!ovtO@VZ))rsmNUmyz zY*E3v+^>crO6#N}v``1y#8!GFt61r$sN==O)=?z0xO67eE^pb>*YO!ngOw22j=nxw z#BDA7AmH_@0WUmCd2fenWSXQ?|AF`q@&NXqxG0TCVJU7;b4iFyPNs5}(=qrdf)`}- z70Y9%efhJXE|dw<-i*Au{mk{*{E@DoF27yNKJe$Qe5_etHq|{C3ix?ZxQLYs`)-{< z8rp)Gh|PI~)+L)#yg(;QdWdnY)-2~KCFkV+;lqc^lY8+-XhBppj{WI2?I8ooAcs=X zSLvStA63Bh=XSk;XEbS%uHvx;-EXW^M9j@tIF zudXAt%+LqFBj6?wzcau-FaT%of4%+p|Md2G3ir#-j>NyX<5<+tuYI=M5p0!!siZOl z;=-SxGFs&a;_^y4w^Hm}I7=L&P#e<3rd7w20bOTV$;fy-lt`EG5SQ(8L+6_+*ifF2 zGI2q3V`!Y7Ro_x{`Z)fg5hc5So=_i0en>gmPl31f%^K}efiSXfG8_e#8ZE7fU;~$6Ip&09?KT{+yo#w^r`{+B&;}q9<+AAf^=ZJ zbUTDI*Hgb+lgUvZhXSL?`*wy6C{dF_XSj=S%N~ogrgG>$D@JY1+@owgW&j{RFF$9$ zC%;#K&|k9_2|0i3Vxsi<$TrowC$`VE+jF$H98&kw8+gkPo8SKXmZPFQ_H+H;IL$`v z>iGwiQV(5Edi$eD0+c$MkMatM&u^>E3xhzk{x@V3*~&`ynb|_MgCu#yENzSAi^e#Q z(2MwOe*A`}RM2Hcq_Z&}d)MswR9Z7K_mrslH{$d!}o0ymo>92=qmXxYgaJePF66Z$tEMo|PTM=Ds^WfF}P~#rX5=+Pfs{oPi#i zAUsu;)DUlCqT{@oKOz;sA}p(#YxoobwM=6ScKBy(>$q`8h5Tk0e1FL^ zprwp+*eR#3TYS1td^ObdH<~xK@kF0!DSPpNQfdlX^aa3YO#3{kFnySjRlu>>tpr;ff9EWjgMI{Ka?ka8{(6WycO&9M`J9 zO{KL5WzVBom3gX8+cC^PFZ+=tJ~#S|2_-vCbB1~HiY>Ni9q|WI>g5R{3mOi%r+mrZ zaFhoc@?lGZ?oA0K5HVL8Fh%1u-r8vD4)jLPeZfly@9XW|)1KY_Gy=JnTzKsaCpF zb=`Bl&f1Z(R9hTXTQSy2DIYV|c&b$|4q6XZBF4Ki;1&?+>(+oXsX~NR{%}bd3$guN>lBjp|Rg z@FV|qw;d*TN}~?l3pQd9jl%VKsx_e;SUV$BHBoV~=^_9CG4NL@0ixj~q+`1Mol_xE zK6B`K2FVX}`OsQTTryAdXFi|NF7;PmMJJoN`Q zrJ!hM-*B#ax^d;rC)b&1^DhnN@FNaRhB`Pw$NiE%*!1}=5_M_aOVriHVwbgusY?Qp zlNaQUj|94Y*oC8%nhYf-;SEqazoa!Dlf_Z_=F>nlY~N5lW&3GGz1rMFLFRO^c;2q~ z6=lL*d1r>Jwxz4G)u?{?gfKsV5D+^DAjX8;mJF&EDyM4mL|K|ms`-dbl2r`=%*lZj ztu|1h_YTVIB3}2q>Km=eL)Sk%7;{Me1;{dtCD&&Jyz;kRO~798MsPiNH8=piQg}TG z&tKQHLa=1ylJ?}nt{ZR*UIcxbfUHxfEq^)>E$}`MybUx~Rs9EL77}NF4Gr4?DjZR1+Y$1spe#C3|1UZosZ5ioY zfr&y)>2O%$-E5~w@DYRoA-Wr}_zphJ3|B7~^iivdLRfYQ3J~9TJXuJR%4)zFyF4ff zltYK|epaQ05R9V?0rEM$z&ax{pi9UFKxYDoDJ&5qV+t;x3n6bwg4tSn4=2xZogl*w$ww< zx$8BW-W}3{f!-nD{08H9YKtQok*F>=FE5l23@zDVrBVHZlSJ+^^;%?uyq{q)b;vd< z>x29+7~@G`R7!!MA&n&z{~7!klAbgRCe8X{pB_zILSQj{ct4pH$SM&ik51NoviLK_zo7?f4I4m`EzeCrQQLD+Jr35+pg70HxD z@nl4oQJdLwtI4Uehc@X_#C)8rEqP)ZbVB;y7~^WVPm$CDwE>v((I?X6j$;H;S%tKA!*3xc5n=>wVY2 zSsA61D*{}wk-t#eY!`cnc!L$Q&1_$WEkXYgZvS|cAcD2!?ds*JXdJF=vGT7mNz#GM z?~FW{Q}n{HoXNY%2x>vv|8vggyHwqA=jk%OxQCq0^8Svbuh1(7)FLRMMurV7%-UHI zb=aS{V&quRR58Ad%{dNs>8Z)!+(gImIk+$$mZlGio0nVKRqf0=0xuinK`x-YYn4f- zpt9@G>`B>AaD;e{32qv>b13~9<>^E!(D8Tl#31PH_P!fK;xNR^WRbCCaW#usS_pi) zF2i#`2nAwm6#oz69`P0o!`@2oUhTF}-rr=P3b;bWJmk!mg%JJ}Dvb){3mLE3Db_rET+NnfN2E?am#=pUMsC+ zt!ubZ1$I;R_$y)BORRNUyupH_J9#&P;i2V6FoHKijHmWg>&xR4O*Eeafwl=d{N6ZX z`CMkrpj%_;s}dE|R|eU^S-*~XR1-S1<`p-5zOc*&)&?R|4A*JHL7hL}3nee%-vNdO za(hFl7Zmp(hk}B5{tyy(Gk;(5KoY(RV=RfAVbI{E(GSWIYGh0RA^tNlSH^SLyAle$ z;zVz;zM1Ecm0$K+UOJU=h0+Nbe0yD3xobV?G@o@-sT@J`7($QOfw;#H?P`7VQlhR~ z=QW%es16Q|0K_W|8@{M0xw)vOQ)I8AzaFOrPLBsV6JDA(-l-5#;yfc&`ctdb?gXD9 z+>oHc5>G7P%7Z`|kq`#VMKBgjtBs=pii+lj%g+f~O2ABR8%w`T{JCPuE=+lFkzHbn z28J|_?NuTr1T6#sB4bDx5#)8)AcYtHf{ZV+LnDsHX|fIbWy_1OYH^JZwD zvN2=<+NY?nFR!fFmLkU#_UdUioCK}`JO!}ui$ek8xBA}*+YDv{hJYcY6v=87?no(Q zc+B>O+phv1qdnX-iOAG{#|G-|pA!g8j&$*xL=G5pRr&%F9&4RL>G@ZYeFNnN87^c882!xt5b z(+P?HX_oGLWk=P7_5|>(G)k8j+BD={nBGT~%}`twB2v$Yp3HhR8>|a-wNHyH{^V?g z`s(1^yv2P26g2`xGqd&9lpAqm1M30M1Bd;dl6j4S z#)C~06W&A1SkE2%H?2-7+0r|BtY^5E_Z6ls80?G2W;FhE*xuWC9yG?+6^G?>{lj)+Xz8l<-K}bH~NB~fSa=$ej5Lro|}BsxWtU?I(Th& zF#!q|psEk7z5?0NmZDXJ$?T6eR2ASJE66IZ5-9bBm^pXC6<<3P2XPA50>9*@1q6Ub zA`$<)Qr*dG&X&^tpp;t&s;oYG4)+C)|4Z$M`7tU)i?n$9sIbrol$tbm-a6SilO-`4^egc2C$k0T(!ZksDat;nGS<{Te$Ydu=fcW4tm zo84zHoWA>tc{>o6ZL5^XbQVN7uk*_mnZW8BvnIYMa4< zV_RyZn?DJmN7Fpf4(-$7;D?d*D+_eS%1BL6w9Bl0!k4hGDdp|DdcM1SUBkx6#g@V1 zT3Nop;JaE?b$D%K(;!pogD$7gD0|+ZH|Gi=Y|MhKNIWM?i7f$PrT)%KQ1Ym?ycBR8 z#PMT5&}&hkBSk4l#ZIXwn^2+f`Qy^XF%;Kr^FNOdl)}3)R2Iq(rroJ{ zPz$uvlt#)GQ7_8c_kS~U-Jk$>fop%&XaEAK@1IZYbUCwrv!gO%A@@IW(l1t6x+3c3 zCr=Sff^+LL>NasPpqCC{QE7S=CIcT&6{FBhL$6x2S`Qv%v)W%}`6L@3ge%*N~SYFYa{s zDP~lF!_jF2oir&{p>mM=UUC-#SX^^Ii0qCq1L%sI6A!SVmFeAHO|xC{e3tZumGU+L zt@SmcG5WPx6dQiWQ z8)|8?;bsNGem5Xs$1N7Ct7f*k3KqbVlK=Z!0sh$lPcfXwoGXJh8_N)~_MUj&K zYn*coapF<>lA`mTL>md2+T!#5P-S@rE4xzzv()wtV)8qR-C{0b=s^d(ednOc$gZ@^ z8n#JfbNyZl6nh{5{*&0ag;opy7ioHXoo}XeYkt0I&KEAnv#zXw=7;0ksz4B)JhWd9 zG|Dlo3|D8XVI?QtGb)6|c}fy#T%Llm{Eo`4t!y3#*L>R>8a&Q_K>|mpRhxp1w?PC7 z1d~Sv3{NcpT-;Wyp*wZk7pzu-p(3rfRRPXd3_=7=5VVf0305c{=oGMY;iF~2!wTHSHB!4?V>0vyrujpmY2P4%=RN#-MWj&c*TnPYcLsP0cnP=;%#T+& zA2JM$!mnyiya?Gd&4*l3pY*i$CelQPduKdyI1i>-%>DL|IBUfo^WnLvRnlZ_w`DV= z#S73OnR+R`Po4e-KC=CzD#;t+j##Rxw5a9S`~gvSPr4RdQKn(RY3_dcKx)RoXwOQW zNEu8V(JMrqJ#1HXXr{RxAUxpehY+q+DNe_Fny6g&fWC6t)rMleuTMqImAs8gIIV>s7a&^IP@h5^i-!BG+m zSiHdD@iM6n@ygjU zixs4WCp^32M;_&EHI>;Z4+Dw2cT-*Cc_6sALXiQnU8rlDK&zabb>Rc^3AZM3!Z*Ai~+>qPS`rsL0}b4cKI zmlFR4>`|hm8WLeKLTbGPJiyL`Kl>$834!#LuOxW}3d`CIJP&cLAcSf7gM01+sq~OS z5WVwGwK04CWU1Kb1zfj?Jzy;~-Y)i`H-an0A$)#d_rRBckA46Et8n}r|JBKlPv>;{ z(te|G$#ACf_E>$vP=+(s6?u^zzsnrw<)%l~&oxm43-m;-r1XI^SysIY7r0o9Hp}tn zb!$R^jSDVrT^-Sds%(!u^IxG=Aa7%Dsqc1!_NTNRj2=!b3l+fO{u8&Tq?izjp(QmB z^DG@(x}H6Wt;<CTzzIIKWj-rQrY(&);c8KzC!aJD*j&J zSbmp%y8VcVF?B_Dty|jkD!hGG|K-mZ2W^Wi7SeitcxP>^lEseJhs;lOjfE>~PCPVb zqdzwn1UO~q33U+=?=(c*dvpg8CR%DI+EJ-7a-9BEB1Iuf2$U6>REgRSRQi^GJlY!S zyoywST(pmrmL`8HT#?g>NhZx1ZZE3=2ynw}tqk8}W&O>ib5Z7wNfQb;R5rdttgH_I ze;7NfxTpfQ+XK?wAV{aQNJ%%+-Hi$Y(jYxUgD9XhC=4Lo(%ljwU6MmL3`h^m%-O#0 zch2uzes^;Vv*+1QthN57%}=r0Ih2&@?ajD76_Y9J^d3nQ=h2iY*qLv~ld6#yvRd@+ zF_9{H#ar;@V6`42GIUn_E)#Pr27qAuA{6)@k*&8_;9@B%k;!qU#=vjmHddF94>2_Z z={0XveX6GJV38cm&-F4_#c)){r#-|+K4Pbs;>K8MveT-Z2$$^R@k9l7KDjGb5icVqD81<$tNySc-ey0S8Nx~6VjT5`5>s%wjTRaHv9IS&&&nvtCNHtxNnYCnBNE<46vN)vx< z8yH+#ea0-w+B1gs+mCr4J=Mitonb|giots29h~P$fJdmfU}V%Azfysk6q#X9GSw#X zMn5cr!9%%hIv!aEp4YuK*KdEm{!mt#e7f&Heh4FqKF~7HO}hSuf|rE8!+SL1GUC^^ z+dZ%S$L#=jBF)8Ewo#Z5Bj6ZnY3#_ZFridD)>@GiX5XA6N9_gmu1Pk2iFZ=@4gK5C7dyGc?y?hA zH!CyKe^^Rx(%sHNT>GRqS=vC`7&yn0A3%_1;~uXZd^mxUF5b+?3e|ly3qAd^6}wp zx$R5fpsg)(48cbemJTe(2GGa;;cyzm563t-4`B+qWBEz6C01Bgvh}jp6r~V3Y7N6| z`sA0uY+!Xppi^IblVE07>xwO`c;X1GQb}ox-Y96mTwNs<@exUHZjXmg4Y_nzJ7q7ArfT^RE^2&y? z)Jn$^)$wY$z$bE^dr%~8x+(B)9Yqc@mNY@tu4|W?4{RZ>ae+Z{@Psm-?D5QyQ-Lq> ztYwLne`S~g*`{zj zJes;o0S`XEK!Tx0TGvzRi*yE^$?%W-6Qr2@3P|t-3Y?VR%MD1z@FV*FsZeMEc1Ev! z=^t(?0EGfb|7>5(yR=?B&7q&Wy~rLe)91kx;a?j&FYpNNK09q&C3`n=Jb6LViKM)b zPhp$w<&F6<{d+?5J(J&}f=Tz+E?h(571aT+S`5pnLTs~JT(qhd~^1S6LI-aSb3 z>L3rJ2KQs?~a~N$a)#FCmhm%!~DJ0svahMf(~VOYwT=s5KoWgT-(n|wjK~N zOmg5{JK|US-%gs|f6bi`eUI$yI@&sJq5HPZLV(mFf!~_5f@4`Hg=P&;VXrs!_y0Do zH&syjV!}=Twx2GRna{n%5}@yZ zC_b|*v`UOgg8IpG^L^HEXX6_9pviI24My2FSdYLvTzwD4v{lwb`&*-aDbXwnO ziE5?mQ@va;`=#HLK}sk2^|PP~Lx<|Zb|$W&;MI!vm@?CX7hkk;LPFZ@^LiBz zKED*vU)0eYV@@n@J#RLNZ~7Zj#N=-Cw5AxvySJs$7*x$5?9?xC_s!y01#Lx2CdXji zmzBRAw24i{5$Rl!k2>yYTiv$xYdX05=*j%Z7KK5szH%I@Gji`b-#KN)O#p- z2IoET5jFERU_s+@ul@tjUTx__YxNB2tBbqAHy4%5WW4+WCbryNv-axenYS z)`(naK}=hcW?cit#4i}3wbx8IXF&!mVB&NNd9UmRRe>iGZ+(1HhM7Umy(Mzg-!M*^5#H~i=cl$!ki%!Sv zwelJcvt+cct!ZB5Ld{B$O%l(<50A3%OI>?Xiz>U)OC7T(xzu^)UYktDqS64onCnq* z=xmdcL~?9u(~L(Rg$(DnOD;8A{{q&}()sts)>7_G?A1jK%#^4V{V<2Z#v6i#Af`L- zV&@l)i>Q)Q>sJ~kD)jqi#1;ytX&ieWe;{(1qK2_wlG8R^Y4B6_8gsind?C!Q{J!_A zwwNNyN_+lP5ZuIJ`uz*p709RXxSk@mMl)a?66Y`agy-$=7qv=XD&&}B-dU{~HoIPb z`)$vr5H|TvNRp5)qBmT^j*p!X3!~4AJCmCYA9H~9`4dM^jrm*HQtP~*A-nO8wsGSn z@hZXsX?~uQxi)Z(P~~>~y0gCR%Yi>6*Qp>AM#QFF6piL?LW+H0Yf@^rOl;#3^4fNO z#%W{~_xnn)3$JcelU|uv>)%6^G){==<|l*e$t?2N5}0kBb!5=vMG!a& z(f$Nqd_&TWb>KqlLQcS6?Y(lS&vJ8Nc^Cws`Z^XL~n^P-W)MfR~>UzWXtqm8hsN}qxXoF9MvcB5#ZTm9W-J0yBz}^y+ zTxeh483*Urd7~)s6U$!yPc0C?9R*}nQv&(U@z92opr5}+vnWEuT{SCkdW7{!3RUaC z?ng~Z*0)M&n}|+JnQWuS;PI5Pu(%vQxw^FPci&;6^L}sI<+KI<5PT6FI*`r0I^%nB z7lN^TM0`4ulO=pA=dH^h;BG&$Q^#brUU#t;N>cSMdkR+2`9oeHBJ1o#-lX{RXTQ=K zWOv?_EHQY_p&)(DWQAV%5f?YQUGj1e#yj`JzvANhD*pM>&LEYj(1MzP#X5v(!GhS4 zf1Tyy%j*7@9 zlQxFM>>XY3tOnmz-R*ta@;CQ3V$)-53pDBYJ_hQe-yhq!1xcZ`8I3oyrq>+JK=U(9 z(5~O2eujmH5Z$7p@W>+M%7l-z9#ix77)JB~-T}93JgOfSY zj$7}fv|VAP?rkLIrwHDP^Dhw8dt=IAo#--`>m z)SaHj&&$>nnz*qq*zO@)$&89J+)y-22!|rWZPyZ$r-}qa>zl%bSv1|VZtt5MT<-|V zpC$t_M-$v>EZQT}RR__(^uAe>i~Vp9v?nxoB=@Ex{0C$1F-xR=9*wb~z-`4Nf`^9z z$SSQ)Y#e1@Y_#XxRr`rr`)kZoR5l_vPT*N!&=Kqap^Ylf`o2Z{t$QbXix35Uxl!zV zd7FTmDAimG8fx=9x~;XUgL|<~`DovEA(sWmT2@PkT8^p_Y?0cC#cqOnTZiBdr694x=xu_A?**V?0Gpp?a&c z`P07_33un>Wm_LxM!YSGY&;n|Rc9ZKi$gC1Z6S@2w#VJbAh|YKqz-7@lG`&OlreWb zqh$A?{_HH#P%B4HN1W^7^5{FZ0KK#5;_P#~RHz^_F+}u^8YR&1Ae@A^sj!r!xV$cc zaDj%j zPFg{H&Pkp1HO{3c|A|OtAej82Qh`#NvkeE+>?G&VibXI~MJE9M0m@2t}&f(`j zQMx`wqC?01xp%o%l~J03@mz7dMh*^`!ERRKC4->ruZHGR>8=9Mef>!Nh1R#En(n0d z368sY6!Klr-kuOrBnxpIQ0k=Dz#Q|OCpg%DNPLGYRS5FUzCNQ)^922ywrBZNlkX{{ zF;xn4lQf!U(uSpgj<+2TL-A~K_D*b5c-8#vwHc)$C5GlYQf?(}?RweR;`v7wLh75G z21DzwES1+-k0Tgd6d8XFL(_mf-AZlE_yLYG_%Xedi);+3Qq*g)x9Y#t9eUu?Nn)T% z5kkWah{fBDXCOK5N7(ZJHD0(WWIO)v_5jKWac1fx2!&D{q2tP0L*ntpSOde>Nr7O^ z_)mMjrvYo0ACJ}DsW7k4VMjhxCM6@P_;0?#IPlXA?DS1&WMa*+N>5Iz@kLNszd2$V z^iGoHmwif~{pAvP4ZB6l7bW}D&G*2Aw)HaDk4qUNOfMXS%9B{`56x*}_v}%LB@V+8 z=fk0>Q_#YGx37Wcq$>{=cXwDaUVN5l+r4Yg#t}7Yf3A`YogXL(;A-#D5`5eZSSVuRfG^qwcFy7|I86<@;9=_t7?X#fp5hG&mi z8~`l$Su2GGV^LUdQf{PPz0`2559!hwzInh769-%vf<0`bm=xuTM;6`Jxo@3h29$IV7!0jR z%=-h((u6S);r2i$MhW290b(A|jsif+a0A>=4s5v52&bVc*M(-Q^Q|(Q?xE_C-R6Y^ ze9E}qN7K)5aijdn2kTA#y}ffKedM{x6ZNd*oUe#fyKh!zRq9RL#7pxWz2Ov z7?f!*NiC%x+Mhnm$UVK37SkLe(Ja7Ls^1YMr#F1~AYXKi@t!XbHKE*BrZSIeZfy{* zQzGu_F3iGJN<>&WQOVPE1^?P4Dq0B-M=$svB9jJ!L3L zS6sO7e5>^SRHLUdVN1cb<=CHHgPV{OvAj}J{oKcf)-AlfFp+7J+U66K zV655EGQUH(e8cFFOw%%W3`*0yxja1#ZGpe&yz@u6Vm>MR0mHEtNeh;EKQowZIs)^+ z+IZ`hJ6hQ!bX@Qrn)s@O-d@!Sw!1ajSV$x>cJ#uy$>qJT?n-yv*F(Tq24zwUBJ%n9 z#<1@rLRL2stNX&kb!xJc3&R85gXHBQPf#t!p-oOpb!?~ZWh^ck! z&B0gzAr;R7}SrSHm0zS7;I~# zNsxr{kH52KXwHHibz>u|e{FW39^RtNl)&H97H$qaJRrJ}@2@)>Cvt77>02(0x7jc&q^S-mS0Ui|uV z4}TNzY-}OhF?_T3jg>rSk#XuvY=_PWc6^BANl+WVS?1}zm~`Wu-;1yT_Qucv!$cl$ z)oYIaBc+9;84Sq{)yUN%mrrS2SRSYEdj1DYBS6U~!cC8@H~}wLF?HH%qP&-F`d@=G)6SypyyPlp0J!(IcRy z@5fW_B{p6oW-XNvOIQ`JUX*mBZpJ8N=MKC$(8bB|^ei$%yIq{z&bm+>TejFDa-oZl z`s}OzzQ^%*jV9C&ZL;=m_4G&4t4pzrr6p2`#;ryTY`WDh?9PRG*^e!8K7t&%U0-(Wx?+&or z0DlwqhF+~vj3_B!E%I@)zS=s6+H^K{AuFIqLpkTGr&rl4SwAI7taXt)rX;pA34!mR zo{4o%cvr)){(6rJCd1v*FzoyNBCNEH43Babw4sd2$-$TB+zI`^+Ktknne|uT+AT_l z(F?IlS&yLwZMl9BXV@xPN#K<`LI$t0XO;y;L5i1&)-Qgwu52Jk!Q})Q-1s3u1KyQ) z$8vqx4#V`i{6+aRY4l=5d^IT@%Qkk?+_~;ziy6b^L0RS59%g6p;M|c^R2t9)=JlEh z(GXxM0$gG}K6V129TY0n<6$F+Qg$HgJW32mMKRv(4beas;^*P8<@VM75OUiUt^)=v zl0U)wKUV5b)k|Hjj9C|b2kNzV!j~Mrd5Fww-$7~inw~m64#_!<;`;KGMriZ_g$Ot; z>r1Ebjr4#f_TLg*xfE}Kn-}T`iPrf_f}oX8Sy!D-P`@dRJd9!I+x4AwoVq_0jhVar z&W(p%rZ3Bg^uO+%$YaaO=8QMSZk2CbFFKEgx4DCx`Kzagk?XvN=x+p4^+mmZv8-?Xzu} zOAgO&!DHA^@sj%iSBB(RTnIZj2(`8sLAd>owl(&7e2?-El-;X)z zkmxBVrT(Pe=}}y6Cie!W`RcY3UA^oWEJ-!I+)*3Dgg9J~dTYc`>qazAs&@ zCys5#+`60@iCw=Y-u>(HxmLGN!LMxb4ZfGEpL zY3ZM)&#yl`hK`Gg)qn=${p_|qOGEYhCbvwAPC!*~jNDG*mRG@+7E~ZG$Y9hr?e4doI-O_&2Y_ z=5X@DXx>kMoW|XV6vIUFG##_gKh%)5|5HtvDf#pO-fi?hCX@gzz){)=%d_I60X~F# zmJw=@k;SPZa*PL&NXhd(^Q|rMldWa{-)XOTt{@mh%d&RuJ?BSC@7j@|48OFggI`l^ zvc$JPi0hk}D#o~tGErX(QeAy-vKLk|@o?th*WL>-^K&u%{>KtD z;|~vY&7cq+G!`%0U3G1YdX%4IkI{Rtg8^Lb_Fysna#-Lz$1fMTt_gNKX z?E;4lF&$hVEj-@4M0i}>-aQ0e-)*Mvws@t<#Axo|d1AcxezD^;*X2HL_#&2-?$pTk z@FPtFnlmy~=}Gj&;)88X*xL(~Cvo8iSp^oWATaUK>)WI7RsRe1v$aA7n{LO#gjGY0 zD*NjfrRb(fUCs)MHjvaeXgqAS>-o+vUB~RLoh@g3sHBDFd9sh}teHS8bwvZd-^E7C(J>QK}tehUojmol@gN$&>wdQRc6xlKK9--3lyb#o=Ik zy+767ilK8em9z31uo~#!mDYanHK5+1Zm5C$F_djRlVrb;YLhFZow-OB^$xRyNuy`L zFJtgxQ#r5t9G1?=!vN}?HBd58(czQ8#ep8!RlWY_)~5pkC0OtljR#h&O&Z5rw{m#} z&2gt$jXik~Fap0i)CiLPC@JY7t3RP-$Li$oJF$jEj@UhRImr-cBb3K-u|2ozvCrTA z(H&F>*M03WbmQRguM1MD#Ym7UB*1Su?UaL!UuH+M{(j-fpkxGH3Z?vj)OJ!1;PYb7 zsM3>wC{_6CJ>31`)PU39_uju{w)HvBA!E{@hWzI3vFXG5V}P!7yNb1>EfvvJ&Q?jL zxEXO|(0;r3N@dTS?wM{wp~_jc78^bFpI_dj6*DgDH8Sfj|D zmkqfMJSn@QNum3%Bja!e1(9m9xq6O2_K<`F+CG)D&#lMQP{kp#G5Bp~!IJic71^VG zqUF*vE8IIjPM(o9{QPFAB52|T=O9?q`~BY?72wT(rH~!-zZ(%R)c$|fj2X=(H9K{_$A@+E zcipqbienqy^gj)4Jf&aTcaJrb85PlACV8M3gg=YH{e`*FQy=o4V6Vr5e-gGcK6+@* z>=npr7OvWcoG(SkuUI>B3y;F<=40Obz-6Sd(ALveb4{6C+)cLGv73IeIk`>!>S4Ub zm{?jK1~MEg798*rmC9pF6sp99G5CtUY$R5H*SBY!#wqST(YE{vu6m6w{I92@z*p`K z%d^ws0*Y4nKiD`8{)fJBeF!7WBNEDQRLM#J^16xF|7#sS@1e z1g7cNPy=VYy5n7!BbGA1vtS9ey;riTN($jhu7(JjqQ5x@%N>8mHoNX{h5QZzgY0aP zJss^eCHHs-7~D0sruByDlcZnDYpxG!&Gpk=?vYoRQLyfb%v#FC{$$Hh6;pqc5uODK zEpK^@KNnX9Jv?jYesrNNVOT$d(4s+N*xdl2ccaZ-tG;e+k~A87T^0&QI(|U6*Zb^A zNr3-|MZQN*n3^CeV#5emOHmXSy(dTGw^as~(}BaT?n44z`V`Im-Y2*lzwln{T5t?^ zRkKaEjbP+87z39fyT7-P+^HHdsr7&mZ6R9$f}-WIrw=8nqGdN_-BCLpNAYxWQm|yj z_L6B-m!o@G=`kkzqiuYW-gm}qXly8c6u=5>A1`U68$u&NdmB>jx~SO#o>1+I&eCKE??9oF zMN=f5uiFGw+XV+fIEN|LlvjZ>vc)DPCm?j~YVTpvg@A~ zW)1}a7Nt3JuQ|MDQ{%n^I{}aI$f&vLM@<&+?%ZC_H5IkpBsEQkJvH56ZNco?yfVf( zezm+Hw3iNH+CPlcIKTELLM7!%^xsCzpV?twOh!Aot{Hh_@n8t$K~8Q^sD6EBEm{t2 zrT+|0Vsuy$QOxm#^H(9zCv)dbE-Cyxo9eh(+XT}W$6{_PlGh}#x8b|Nl~Ybk8an;@ zOi%K50xAf~w~$B2DPjj9pnCXm$}wCrQD<#QIK*PD<>biW)5BHfF)>WvwAI)0=IO8C z7iZ&cbg@LDOXeC`XEVinX?sEUx0;4+xJnA_KRUzUm-G_&3-KLZAoeC9a*2;rD=l{TEU;P znPxIc9-Td05@Ga-VtFBya7cKZ&T@3KN-_xx_7-;H>Y`AapoYgN(VLg(ZWm{>kK|CY zPbnW!o&M~z@!3A+FM{TYzTL)mLCJ?w63Lu$sIyox19RR23I+w~{CouLy@Y_DPXzFy zEYt$J&3~IPBS%)e1VxU}l7$-S&~E&Ii!qNIi0m3d@`~T3R9%8dR&-g(9jtQ-wrCH5 z%-P!i=9+wf^u0G`Tj^fdO;DZ_S>F0J&yX=4ylX+M>TpU>>=b^OU_VkZTq!O18@|01 zZO@&xtjj8y?rt98agFR&ev4%QN9K59W^ms=NL8Wlf=_)}xtcFl?hL}5f?r1|%f2|< zqyt}@wFof!kE8KE-!mshu+bJM-m1PYmruL>iNDdpw(ltkP)N}~1Lfd9Iuc+j4s@M( zK!jAB7AplFrt)R-jo?YD$uKwmqNdut)1b9!ocMC;uEb(b+x z!15q%9$1|`zTa(%@tmHA>j&&)W*2wbf=5+WMyjObZ0D2&mICAwOCkvpQ*LHii$~3a z5zWP`x(?4R9|&BqLIUOuYrc4-|H5-e6&$D;b*>&2lC-^5CY4;_$Fq&ACIvSCazK$l zk*Gun*;UdKkYfOeEuWQHl!C8McWocHU&A~^RvN|#tji4*pwD+}X&{3;55Ws!OmDl0 z1|x_#EZ)Ste;p)eok>3fM~Iiqd&m+lNd$%zeTkf5oaz>M;&1dlTR_H}%`(2YfO z8(4w$kT7i#V!KmkkLu=TO#A$cfvXpdHx(DUN0Xc9V-zZz;k|F*-POU5<9m~{g+ERY zzJDiui06ZHDaDf;{`-xvrmCVIb6*7lY`1+%NyIEfse_bB@XoD_C75z z*gO2_*!JPGid8TxY@>}L;b6ul48z5cK<5J*1zI9NxC1fbg&84xa| z{woB^%N9B)tJRyGp_7M+iKDyw+oRdm$GGjaAIDH4G!-aGsXAX!9BAQ}6hGccfrv-e z&UVQT=zHTNLTMwY{=^9qYWU|#TvnmpXA|evi%`D!o8T2U@0x@g2rM%{Gc_s=V(aIy z;Ks<1SW>lkcD;+P4}z5JOfge64?)i&(wk*QU6gy;J~4h6;8QE^Kfj<{M}6BJ2vajH zX)9OgL=M?`gY&k3p+9sJ40xfY2vOoqgf_HA^YQ?xeD~<4`=K!=5P?)snxglfl9S=o*XtJi5j@{G_9KlE8y`Rt`VnX4eNMs+c8fvc|NYXACU6g zi7_2cu(JIMccE)8E5<+XE~y+~VY`2U{iM3$z(5<_R#5PLa8xywE^IXBO4fX`_aCKs2oD99qYrlZC9XxO}R+ylar8<<(naE0+01 zhQVp$JVI8<9!Oac0K`|=y8RY1`bkWBiV3TqUo(UiA4R8E7a*sad0( z7iGwSn)r;kg$6>aV?#C|^dr@Nsgct)s5Myi}g_1hV0 z0mc96a@IDd(Kco6fSz;nizV`C-|bYy0tu<)yiNCItSX)4($_3WdG~k5?f&7iITn)0 zsCuc}{Nm|EdEp1s2{W`S&+pH-tj(itt%QdBEh z@DDxTv6ZucCY8bVAVb}Z)hwnwR(W4bf~Wk~X8aYVsk+}H7+3d6wg?RXU^S|22Pt*C zBHBo^E-e_B1LCz6{rm*`0mrb?4kVkUXrpk;N1wF?+1=w72rMwt*#ojD+ytDycppow zW?T<5YqZI%-tjbriyvG$Jr~DVpn>iWc?#d%%yFPmj6#NJ^&QgOb2f?74uWl4(yj+o zr;x{+q|L#YN(|5N&Qj4FZbCQYNu#kkQC%pcVx)bz(7FgPZz(ANe;u%Yz={h8lq5Wq z%yAK{`f@Tx7oB@YtMJ>Qq}arA?wo`0`CB8*5zb7O7UR9#dqg*hD%iyAJXiX5sAb0P8_qVIzW9MNpRQSvC#Eva)3{B3Z12Y{{; zFRIQenO?#(y<@uu3EOQ1dnHt^=Ye|P|j3U%?k|~c&lCzew)g6PVk9P%;fNPG4NC7 z)nc-u#C#Kv-3v%6_$1gKWArL~)?=l>&6)3Dhh|ssK0gZdqv5>b;h0SfgIunAt;tUO z^6+xgMa<4j?>Wr}n40n3BBpH0WDn}9>MyVvjAOJUb2yc@T~YXA8LX-b*04J#Y{`+ z-5pNzBkuv3-K8{Q+nlahgH4-9yEhOV#qGSIsIREah#}DVp3bA^WwKkKvegXZs~W*{ zGcSw?ik+hnks$w!pGeDL3afF8D>-Gf<#AQ#9GktqIxp}c$}L`L){QHsh(SkzgR$$o zuwhZ&^ZK(@dEeRDKk@L;dSz7!12AMiKi-C?%KsjL@#x|!J*B2%ZB6PbHj(W;ceLN( zg(A6RQno!4H*5miFRvLlHUQl8$pw=|0RbeJp^|GS?LN^_5M%cL`x=$;qaz~pJ`b>q z60CU{1Ux*iX&y9{G4Yys>>F(`!%TEAkrlPR)7g92T}0Dqa3gE^zTF*GUeWl7Ul`?) zBl$Sh`WMyxo0&KCA?w`QnLXZboS8Js7llNex-RkwGp^vyS z!t%BeX!c0@6$9`_9~+$f9Ap{b%IatLc_T^{IQzKQ<2O+TdH!g+4AORJnXESt(~lUl zH*7MG4_Lne9{^1g6#+Ll7t$cF~{Kjhua)1qKFJ;r;dURWLT73 z3vrJ4r;xEgh};XJG!kOQtmh2nf4Bt^sRi+B$Mx-l9hp#6MDOF-23uOH+77kjKo zk!d`!<;Rs&HRo);hv7;jeEOzbUUw@nQ*vCb`7g-tc(Gl3NcVf`F}mQW@kr#JXpSLh zDfsZO%p6>-{^o@(e5kJ&=2ASv{`!@N2|B;a=fk!24cb(Graje5k}Q|qNF3p{9hl^> zGKXaPC3%3)FSjd6l|M%`T~*BE!53*~ zK4oi~kOmoNpB81FDtBe6OmIZWEwkckQW8aDyz0@@qNK;dRTPT*%_>iDnJsxWWZ6;S z5nm%cKxZ^*5+15BS7Y{-!adOG6h2ej|E6dS@z4gym=6OByIWc`=jzN9kVBz?@?4&> z;d#hTl;hP!R)hLLlHM)wKR)h&L0vTSB+Zr$J|!zMQB!hFJ=eTzz7!456h^@Mp@~< z)r5zVTaOc{@QU_5neKq`MippFUUL2mWwv)4s0v27@`AjemNimGId0?zyOw&7A8vkR z|2@i_-n+ljU1lcE>)oRULKe&bb~}8L0kTv+NR;~t^wRbPqCD*V zSym(5UQ^jOXK3RFW@}dkuJ9^-Rq;1<9F(y9>gvo7cYD1p{qBhh+E}Q=PF2^9yYAQ& zO-QeI$4=1A#jD^?UpU%#Yi7k-e=#Yq?bAINhrO-x?Nb_s?`j0OQ5F~4AicHQI6$nm zxQdYoRChDOYvkbqaBFIL7#-%}x;~k;zp;lv#^_Fd& zD@B{GXkBBd3-M5QxrzI^zxc6gc5R}?hzvG_xphV6fY_7D> z93$I-!*_AuT4D>1c-#9e>-Kc}yO3b!@;N|TbqvDKUVHz}^{$Fj_^8OdeW@vm$M{Zh zF7zVDhA_R$$CA{&&$BUvoO88q-7%4rb_`mnRDv3E|J#F3fswE9Pq&NrDLm{4sRku^ zG)BHq+)GXtf@>G8F%Zfb3LWflw(=-rr$3=j#8NPXZq+CK7mXRjF``5bip>(3%>9D&s15 z4#0n7YW$x0;U1a=3JB_uy3MGr*>vitiCtLdlcXl@_Kyj0wO#Pf-);N)#ydciQF54A zY-Hnht#s+Dx4l0J8Ex<(SG^;sCh1~aA8`+QQ9K!XYbcK|rF>%K#nqa-*g4Jdmnrkf zaHMa>A#XL@U72EPE0L3*fYWoW&dt+O%ZZJ6y)R>oEVpQ1QQfS}YtfI+P+X@c=5x3W}m_rEJ6CYz+cIK=Mn{TE7-{dMNMoKJG-xkj#qBP0{4V&U741#zn&a12^M?| zo!F>&my_AFvAMOLV-6c7P+NUafvNfa(r!d#5~B<(FSRx2L+iJ{T(M3_34GwAKB1^) z22(gQJ__E3Yg7HQ@u$MM91=0F5c5IU|NfL1n&_CdCpE+M?ek#jS!V-z;M3)s=O&sa zR_^Ru5|j=2({RGKZy4^PQY7ADp8dD|0Pbn2NxN|F0O&N)Q^|Yn2KN4b7oAYq_ z2|rze!6t|8x2x~HZpJ6auAaV_Vb>r%i@JBu@ai}u-i|rO)c?uA`uTND;tK^$eWL_a zu!i~GBC0y~w+ioJ^xGtFAuc&xpR;1K0DLWH2aDDX4ZG5w6}8V2CVg10(UHDc&!+jh zK01@WHnBsbW@%Z%$*|E{;di`kp||L0>WHtQ>DE5)xOrMdY!Ig*{)2JE7B67)=E=!I zk?0|bR%gEnXMVvI`RH{nq)EIGCC{F7P}0Rorr?k%TKfzpGuIpEkHUFUerUmVIkqR? zF!V3}-27PfcQJO+SK@o@=yAUf#J-A7ziT>`s)C$OdMY;O!olJ5jV5aL^^u!_Q1yiO zPJk9{q^jq^JR0K;v@t_0p~=sbckPj=^xHFBDASq8%sN2yti-QpvoHvv0wV85C;wNu z38DeU-fAS@4Yk=!u**8m!yrhwW9TFpg%uZ?syatgXh!qc{d2VNUOLs1(6W zM7`D$@#NHnkmlbi!81wFN%EKP7Z`R6y}rZ>+Um-riq|YodyI+()|v1feh=wGdI)0u zYghs_CtsU*E4+O1>BzBMl6@dbK+R%KTDjstC-OYBQYbs+S}3Qq>=COCN8PWvC?$I_ z+w_?{T*U~K|IHqXLkY-v7NKUx`iO3q2Uu$e{`nGWU-Gcyeu`ib>M+gOv%U%pK}rFZ z$lsgE!WtRM!-W3bQZx%LvN(8EAS5mPhD+k$Ya;lT`7VIBVQwDW#lbW4gaT{=wxRYP zH{oDR!&05G*0$B3?v6#x>Sym^DT)5*=81EFI2y5FrIHED#iLGNQ!g@qB5MkE*~(DE z!Zdn*EBJ8efI6G=S-Wn2 zC)a)O{HYFmT25{^C2?zD`i`Y+7{eEd;y=C%O8~?OzSMw&3eZ2k6#za&Z2*S}KA?g} zgfjz+M)At~8)GX_S8Ek7$VU>k?8Belb%n=Aapit4Q#}+AF#x+PXS`&rUzYZB)1!dR zFx?t^Fd>0y#R~j`UH7{#{mA>3Kje>OLTCCev+0FrPSV`*%9^tSG~<0Zs;5Gq8*Wsz ztzKu_Y8?~Sr)QLWYo344mRD-JA*Q>#U+u!nxR(%3sWM+H5NT4Xx1IiIWU8l2yy}ps z-^zZfNfXAT3w0>)E)7B59PfVHbqE(RsLS<$KQ_9GVd{0&VA9e1`|6?dD45rGu-NRj zmPK8f^WCGZ;vP190vKu)b?o*ZlFKZW$pORd3;JKN8MQ>qt z6SOHnrk>vQHiO3Zpj?`A^m3N2#b`COiv8e!@(@I{- z2`4=kS|L@y#R|B1^R!+PU_C-tBvlclzrUaGfVi$q$WrYQPJT2gI5ZqSYFB%65`-Ll zzks+y)d+s+y12RQzUxX^JRC%23C%Ow5@+d_xOEQ%cRtp|67ZLDjo{JT+j3cC7|cS< zcsofG)-CUUFOmMu>MP3o_+xcD-KYb-SEk@?Z`q@*_~%$|c#h|xxoX?1I1y-F&V(W* z*8D<)Z(4h*>&&Jdk3U?m^!||ARcuTn*G-gC9q}?(%z#kEs~UdWtv9H~s2MsArp1y9 zk7UL`OZ;bI6f*wj1p?$|iPUU>?+LK-u>;QJK0y3Kbk_}DxCHKc{&4v3+ns9A3&$eG zmf;5U(C!hg;p5PlW{~ZT_Wso@BD9_J)%#aXa6c00-2T4W8=1CW$o5ejuAkoqj;``` z2^>pz+GAO9HJ;%uhwCtf0dpl71wE?C46A+1N6Q9W&0qBZ5%Rzx5J=7(=6OaIrRZP_ zw!VNPL+*p`&TmfJkJzobo+P_Fd&%2g+is&i4vp?GT{GSgl9S* zyPoWEm--+(DxBJ0VW0gUy52G@syFQV1_UIPZUm8TY3c6n4yBPU$w8z;kOn0c>5|T& zyIX4L8oFbcdA9%iKAz)!KRloK3d7#lT<1D}Ypuk7Qvu`BSbeKcx#Rn@JNJaxga*rv zHI>F0YQ=nlga?lRiMU1r*1F-IDI?}o^cx5EGlt!f!NRs07|GZ*5a_zNg0!~^4m2hL4*Vajq?{2G2t+KP4*&`{I-u$Q(fmb-i7CzOYLMO#{O)^JW#Gw8 zmXxozW%c))AgjIOeE86xFW}^r&L=oWI_&89r(P>l6`u5j1oB@&^7@o4jj;101lU4l z7ACA=V}lxFsQYxxCaZ)9JeyS$D1{&-h zRUEAQDGOYcA*M(>NpZpC z09yq>4q$&)llM*Lbi{1l+Krv684TI%e(iCv@Z0TNf441jZ1|_;K`uOe zWr&l3_p3l>k98lk| z2jXhH6Q~YKFuBUid){Teha_6bg7)F|CagtZT=7lr$cT>Tdivb(+rE+&W~<2;rNMRi z!KVEW5;SQmG8=~(LOysUl}w-QZ3|!Dz^?JDO}I;zPabVQOP8&}1QLl~9c*hWQ$kk0 zIqa?aHy}lUvo`v9Ek2S}w;5Qf+Jl7>9MOa=JN z@(ei{v7=G)C4s+*2`j9-Kr(u*@fITHStYWyciMhhnl<*mUa@@hyfj}zYR%N(VWF(+ zY+`AvbRzo46x_*2!`ekp{D*g8Fj>&y1QUPYy|=xT3n*OpL%aPaV;>Ck( z?Q`#5p{JnxlH-r}47ZacDLUf*R~QQ*E1xT$1s9ue#I9i|Bbu}uV8pNc?*dh164N7?wcg!ALdDh-{i?fzsPGlu6pp5}oJKNIGlK z{>a>3?L=SPcVlclsw07w@Dy11GtYBeIK@xzn)e+v#b+L%_F6lMkhnczV-o)+n8Rr9 zjE`ABe@dHz-SH6vH?wpYkHfOuJZPEio%It5zvrKL#N@2R4#0rpTs{Kvd$|A5=t*Gy z1=h4(lGTZ-MA&FpQksc!8V5|-6BJK@>cNfKFc11RlbDveYTQS=MBYaPp%=IV+~)}= zxD{LbdW9xt!8I1-S9341gP>>{I9|0qPxXTbM;j;wj93>C%O)Fr(M(L8l3r8aD@Y{=71HUTc=cy!i(5^Jm zBer~bcFHOdVbE?hjLti8qs;P9aA3?5Bz9T3CtpYL8jSlSw#Fq+f!zyn7umngWB3Es zZ@f4hE@Z=vlzVN~qDO(XUGDLVAo#0wLLb?;`|R>+^YBV#XyWU)nawQ!SA?eGrXWO@ z2H^X3b|Gv`)c@WM0<5@xIzRzqJ%7C2+JaQ3pWdAUUD^g60_iHHY3(aFq&M}|CV|hb zzMX)#r{;2D)w}UtelzGCBaZiWn==#MOgpw}?G?}tuWh%+Y-xu!aR~R+$EsU1k<&B5 zuNEu3Eq`f>$P3Y?%s>|CsRwL?2+_Yhw?)qv03hs*I3#rgi4^Md6B~W+Xw|Tz3dm46 z6SePjfSo3?a8id}{6&5NlL@|CR11CHZ~eUE1WB5R(B~8sbT5P*(E~Ke*sB7lGb(Tj z8!{_kVRYy7M!$j~60+`Ku%IXJ(`#q~$oXoo^yFb_kCVsD9PL_yZxKSnle}uN2sacM z$^AT34ik1fHl{FuuL)f|DXTLTRc}&MvpxLPu^{ZQ|MsIwtnheFAP8z=B5Y1rJ&|im zocpW5YNQ;|{r9nP%nJPOTqlWZs>x+^b0T#qI*kNI?o@dF%K8~sjjZ)yJI1S_x1gmE zv6mPdHb_qdDovCdmI~GOn!uMmP=nL?gPn{j9Pn>fo3aB?7I0J*_bR`|Y7g?&5pG@$ z@HBz>&kGH14Q?x*Tqwp7|Ray%{Aud59;Zs$(D50?B0rk`V$tzP?&w^O!Q1NPC zbYeP$bWe*yw2r~9XBJ#UTqiKfCz3K<0>6*D6q=;T;otXjmZAQM4m1PxMJ+Vv{8UR( z3-T6Xx4(}>bRHirmrm-yqpy8v@S0#QRgG9dNfKy61L1@*tQ1Z6%uMw5-nw-eMr@iX zoMD7!d|Cfj1yYd8+Wr5iKy!8@g5Y(2eM~y8akRJ)nSFyh7IajgHa_0r`Ec@}fLWm^ zs}&&aKc$3_bqhd6>MAgseoARX1obXYd&`RAOUu`O&?i1*K1UsUd?xHJ>@Mzh{q3q2 zJ<-+rocra6n*t@n$>!hSNFh~H>QOVz#ndwVY&wJa6qJ55zYwRRgAWLX@db*lux!q) zygk?YHR-hsE^4k>ba%U?_J(r{FijP8-0-g}W}K3WUlF^ZvRq4Rg1Fm~;fFagLcQCHgx#Yo|sJ* z-vSP|`#+N$5g{5Nt&=G-;>#@f$L>L^%!468Hx0k<#tf*2nvxFfRy2HzctDZsgvDKl zXnxU8ukdM5X*jiaU{Vv2aD+vI9aa^^@~vxEMVb}aK-ARSH8J|W>^#D1aAb@bo$zHz zX&*@PF}XHS>T|BQs;`P}e8QIiAst_jRM4yr6=nAEfmF(E5oq~Zh}ixaX%MzCFoEE5w(4SejBc?+Ft4*# zqIN?@T1;&C%`T>d{p{p7!ADuTeE`)05~P3~A)x%>qxS&5V1T>JPF)anf)`IjGM!@%i5m%sF}$} zi$jj|ujq?{h)kD}03Glj04gmB5I54T2~6n36ll`#waCII8WZ|_&u=czq2}VEPGGQq zIh4(yzREZ0c(V5IS)=VGoY`Uy;r%cid^)|bTn}Z? z9@*SmV`H|;rx;qaHaba+HtC?Xeg4www5Jh8<t4xRy)y-fBzWi~0xwq8=28*5%h$<#aq4P`bO}=F9N3m^W zQQyRQyd+EPs~sdd`#5qO+-FjNw8Kh|6^)+%noN@spZBf`GT@tj4XyMquWX*+_oG-V zV|3R~UKG8k?AXZ-S#@l>3vl3g33cBSJC_tsM4q)kej(g!>0UHScbH8NIUp|S_0#>C z&3$(0bG1Q3+L2tj_@tPsuJIVyDt;ff$?nw%-TRd15?nif#^R-UV7Em9Eq!dx2*dqz2UZ}ch=yUVkTeKq*dlTryi;z9DYj9?gB_614Snw za3J8vx1fl8z!)z;g%*ztbRF7D1863b50p2qO2p1Yzl7PoLa~{7UF`;Nv0Nc&b^;JO z&?B0c1|sL1kKSHez6KZ%n*s#^7mkkfBePF48(^0E$3GQsWUZ1RVDzDv0?+p%=RaBE zN4eb@J!xpVwP-$mRWWe(@puSDe|^gS0Yor()Tm;i1qCJ>LG&iqwYuDAtH$ z-!nI2iAkYSC_r%`SGyoXFczWvatf^FX?TmZ&bXGwD;(sVsQpblvRGNn5(5h-0;4Oh zCXrp15YRcK@cNKQ&mPV?T*vgyWc9Xx*VnHWZl^K(FPz@mZ2Mfl1>=59&`VAG@4 z8InEfeD+kq0Lx@aJGnuz!f^dtmb&7tggrdE9<=1leUOVG$d``t%o7cT47JD zV11n|&r(pMg5Br9%;xE6Pmux?P$PxB2|?!5CDSM20cK0L8X`^-%y8+?fXkgu1|#3} z(eHc!yygL!zpZeY>)dM1B&c-ApGzNTcTH(HSmydsd4GBzba{t+n<1I7L?a>}^yXPB zq2}_hUxahfg~3q%t&ydMQeOD`DqmNJ7byw)kVltz7HnHF%48oBFemtufzSXBZ_(pI zR1=2>x1S!rN$H);zN-A6UkaPj9D?o5nZU9_!+&jEKvW6a0Rf6k^k(_F95!< z6HoxLFuS`n|K$o|V)Va$8=WI~&1NBC>VLUax>niVT3JR@+iYeCsmz z(2Q-X`M!Pi;;|R`<6>v7nYq!>T}Pc*v%)}+h!|)l{VVla1Ujcd&?{D1Se9f_#l2)z zpU$3|!WZ*+gVUI)8nxXQ^%Zw7l;m%ZP%RCnI1klKtL%%$^1egv8gb1)hto(pe^|a% z>y(DFEuvH|{&zqu>bU#({bliT}S zJOz^OXKh;O7?LjiB$LCrYg!9l=2B*To$xC3rOVvFC^gKVnD@M;Et5X?SaI@jK4p#i zIpVu*PeYOdx$x-z3)0bE((LX#6oG7(VFSAB&gWRqh$P>zzCb{35iky{e@(LMs;m&XtK<>*8<{Qp>@FnpmZNq`i9ce%;be%U+k2vwj=y1~454p^5P#`yqr+4DD4%^zr#5#3pCrUydA~_1BMBbiaIje99o@!&D6>K2OMuneb7PsFWC2 zE#Dm-z9F?fVxjNYP88JiVArZZiA$72={+OVkRlaeDaOhiImtxaSDhnH>~t;-P~ww! z8;zO@_eK}DZBo5`UtrofyP&Pt2CnC44ZqVp7AfmL>x{pLm(N-Xt*V!@VI3(mvrsJA zcAod5p~~ZHA&33f`>8%kQmXllz-HI%ESgW4L!s%Fr|0EZAFN<*cl|;KuDLmos25qt zy2@C})roO)(?P-J07~78tkSq%W>SLmwY4_n`Ja7b~TrP%5AgR zdtDO6dVdoeUo$Kc+BQQ~DWsSWC`b`ak(~Ku13~CS1ZG4PHH3|!li`I5UuoZBBoJBQ z;o;9)@yW_6rIY&ljA=BjYQ;N}opSK+uip{fS&e`Fa6obbYp-d(`*d|P^=Y-G=w};S zeYkSj(6D1$(xy{c^^JjQ+umRkS6aX!+;&8rPCUEcY)rLkLTsMtx#dUQ?cZa4bR)ef zCh@Tr{_m)|1?FW~tI>}8PZT951ch~Jk*iyV!VCx~#7j28%aQq)ABaK^tLxfX%9D;O z+O0!UFg?oA%FHQ}Fy%EFl+sYSZskS!@Bh5xQe-3g^rmJh@z2L9DoM@fF|@R_7}H=d zj3JBLNGmjI#DMXxgLYoo$m8SI%60FruVG++`#4sJzkS)q`Er!K!ZU0qywdy+Cs3jJ z43|@`HaLgqfblmX7J*YcXY-sR&T$J$5zp%Y?~@H+gvvBXUrstldGQo)yGv{H@#{BM zMQypRUqnQNEpKe`elw!diuFIkiHwWw(X1t^ng48e%3+!HrfF_GHcy=9@c2F4bjNck zcNGuoQ&6B)nP!&D{C0-s&pMhA^l)^WK6JLG)=xJJ8?iJ#+8>AdVn{|AH4O{2H_hhO z>R6QJSBVrp2;A@tnQuZ>3%Lf2VmRnHHk!TdMQ#)n<$eCl64nq9Gq2#r2e7V@rYoz$(r$t5>0K%x zys_2fzGM^WUc$A-)=jC9^Yqqv3DUl!=9G$eQjCxf-^ zn`V|~5%i>V!fxQi#QjO5pCOcrPP;$tv-eBmBD}%6@>`=-;}HW+UA4C+K`)(sQW(&F z9C$$&qXP{JS~yFH!l}5lYKV&?hje8fXv+=yzwjgw`Z21Vm$hjG%~}6FXcPXt_v$U} zoeBvs^Z&j%@H=+QX2HPZoW&hdOERjA(Tyo9p=;?08@bxHiA*272IIERzG99#k90%w zmG#bGU$2q{*+wk5L&4_{5i9fR*-cld$~;v#=+?uw=GYmK5RM#} z`I>Occ6sS9dikNTXC^0Jz!5rE*mTsbQoh8nuIKGGf@|`Ay-jj2g@Ax(eA2?BD&(_5 zhy5G-m6npi=IJl}gxX!C1dA%(qTQhwSqzM5DnFRDCovFxOd64!Z^@k!T(N#%?EKmB z(RTE=lhoNqEox2<`$&jJ|F_@;`tQ8iXeAxRKj$8W;cZ^R#&^iSG{`9|ZV^33Vg0yi zigH3TF7=&n^(&UqckFCqy?x)r42M3ARn3#nzS;kK>ERs)HSTl5XKvJ%3#JH`=hawI zFQr~w-*c_F2M+II?^gAM2_wnN(`OUm*ioJ`cP$w+crfarST@jc7N+)O5{rq8 zj42PTWzI~5OqI6JDKZLL?fj-$ht=S>=!{^y@w4! zPlmO>e)3vnaJjwYkDLs;AWG}y_v`8tQU%;1U-3K`h!W}Hpl43%tr37OWL=$!nn7OloNMj8z&OzE}It*-!E?9sM-9Z zi@6L=A`;p$!Lh~jBr1)9X+zVixR*MwMHj+i8WH%!-T--3hxydy(Y5!5q*eh#@knl} znnA8Lel$hc$SX(W^GYav51ur-wuWb^7DAeh;I4vKf6srmB<$vyyy+oBhN~0g1OL8k zJZbwv-Dgr6z)EWmP|FdYHS?pC@!y6iKGiN;hzTh*vw_GmeUkCIOLCTM1_rbz}ySBoCt%a10L%ES>!8%u7xrSG{b@S1x z<`er_2~Xu_XcLp;c=q`AUxbD*N3wmq;2w*AM*}aXXzNTs-oCZc#eM3ebrBqyWjV5o z_BhtsKQ;};E40%%Q0CAAPzaDIHPr+Wh}$i|e~5#b@!2x(-)M#rLC+nH!z4ke?hpMw zrg!OUa4FOpH`~lJG)d`mZ>V6J0S!KgD!&*gbNaXcarS|$A_LuJo<-@sb{|H z*(Gd(B9&ilh&3XhTMLj+V-PDJOQV;BMRo?Khrc(B-#k&%)cP2(_BA#3Po!1aGLHR9 z|KpTqG%YjI^0f+W`U};3_VkMpy+*pbu~{c#X*KDkBpagZ%0i@&U>}~ePe*| zl0C|0Azb2fZ(~lL;`2%0ZrJ-e^b{2Q!5#HlvJ5s6P1=ZurIL)BFb<%%$^{TRmCZR%Abo zmsytO1&qaV$@awhW2F3Zc$R~mz}yays%cW+8UJ5{LeIQn?qPQ4edk-f<|I5Py1LnH z!kL<7xw$HN%>1(^=O#p43OiH9*bdsed4)grag-?v%B_Qaps4N`hiXx94=v8bD}kr+5sRc_&TXF>P!@<`AjPZsMyA zhyX4&5OyEIp&+Y#8$Y7!6T5l3D$_o;)-UCm=m-Ou>f}r|`O?}Xm1-l0XnL(ysf-Jw zjrWssAHPQZz~`rzHH=jCQg9?kEh}?QFAlv|mqFgHJx4P0^hh@`6q3u@Q-?b=J>OC0Q(c)%-%F ziKtH@2*kY|&VR|+_UykXfjymMhAut+YgF00Ckg7yW9Zl(_4#;X@7fM{t^$SD`J^Bvx_u{Sd)#$W#TWq+58k(oiY3W&FGx`Bn zjz>L6b?f@RX!6$%VGOs&huhf0-R}og$Nq|p5e25gKm5J!e$7kyp1NNfsYV`qzGQdP z#0}us@XiV-n%7Uo3s-PApwH>ITAaqe+$Q6)Cw>6sbg*5hIsdtQ^ARWE>aw`a2?_nab5>x4WMuQTLJ(M4%8kXorswLucFE# zAxP_dITy@ts+@hhXVj;Xq-VCB_pGbkgNaEDLDT=ctGI-++FDT(*IczOHL;)ArdsMj#~li71mo> znL@D+ysBGLxA#NE7wO%z<=1Be8{2EMAby0e_Z-tQ!s~nGA?);ILDMp~EN6@!yhBsu z&+3Y3%e{OZq<^4m@7=iAaZoOg63G6Y%>JtYnYUo>A4lq0?ITVBp{yB144JHAd+PdZPL#|<13Bi-$;Ym@{LxZLv@}V&4Buyo}*I3w(0YDaH zEc!F6yc$hzfO-s#u*@5!mtqN1yaz}+r>(vFz2-gN339)mMy$VBReEGz-u^D)VhoI( z2$nclKH7UcJvcX`@{Fok8q_Bluz8 zFle+e4KGK;Mx-BV<`xcQC;UyFSckg@@mV};>;yhzV$FV8P}4I8BLOpm(SD6MVj zp?mwOlVMz^9LLhihcixE9qpeziSr-40hovl6$OA~i7&L#ICM6ik53(fBhf}XZI^nV zX3ZGPulolM$&Xs}1E0Sf>R)ekiSchnLpbNwDi`4IZMD7bR*#AG8Z*@Ut$x^t;(OUa z!TD;Xn|NZZ4BpxusK-=W`lQ%&sA}@z2js>k;7`2;{U%T9^P@YjrZQpPV~=ynxqgmi zj?YT>4Xz`08nbPW$_vHut$r=XNlrce1lf`*5VZ|7ZZbt%9H(iY0+v~cfR6ipwR8jyn(1w;C=CET1&$(&Qg1=lc%?(9+BMxCzB$e zdz$;RZ)XA5`kt2(yTJpGVu-6X$#k8iX8Y4$o=MN5NDk(M($s_gUd$t4Y~g(53Kbm3 zbvC@%M5sPFhcBMvqL19Vs9H+QPt5hp21ZB^3~sn`rjf^piJA$b)e`+kdp^l1oy5g} zyvdm%NKcNB<>t$XpF)QF&sW)`3m7bMF@RikRk}jYhlk2GC!xDlAH*suPOA8WUCAZa)*ty~$J)Ww8K(c^aSyKZd3bAtmVPms4DF}=*O zQ6^g!?+yQI%oBWjYV1^vDq@4mR&=!o)d~{3qcTQ^<+2sOwEy4rAw2*G{AUjUOdiY( zSh!(fw0VkjUogIb&1XOst}bFQi16;tsa}^isqv8Sw@wLAc!9^)Ac%yon8ByP4X+8; zJ@DmlnIPkGw-4ATFc6}Be*IE>K~i*nAE`(;WOOO+X{oXHxafsFTe}!6(9z^r-N(_u zhqA$Yf3EsQvG~*?z>~@%?9-}VNh&7SxN~;}3ZafA%Ex@Hm7;R&*_ZZM(joOoz#E95 z0p7rh7bsn!+@|ab08SmI3Aj;!?Bg(2+ouLta4vD&&8oV8C1S#kFs*wI2F z=*Yd@Oi8bQXKvNjox|Ya%f%zDE!;eJ0eqT+@!$kQ2zETS#L_G@C}IZ)A65oosVx#Z z6l!(kk|Yu}9nt8D`kwc9<+HuMZ{KDSfAt_X11xvhYMtuuWAiGkG836%~cYJ_^_7Hj#TvR z$g4p@15kXVpHvsr9UWcqi>HLOobaFWB}Zqc|0Oi-cq3}Hxktr zvJ{l^G7v2I^+fpd?Zs1Yz~9MMFF8cgU1nL(!+yOI0>beS+N<`%{cTP50`G3s%Rsg9 zo*XFMPjMx+kc2Li63AVJvmHD5@6p$f{+n!6Y7yNxrGto`0BbT^EZe(>Qa6J3bD29PhNdNoXvJp^sil2mUFh65?ky4pB z_9Nd98JSDJDnm~Pl^pbkDh8UPc{e4(!}myvZ7;c%aa$=j-%O;@blSbkRXEsz8mLp- zMl!lZ@VJ;9*QlDU>n**xxW1?$a=`uR#+Vz#853qf2IbfLgsaS_UA*c9 z9wG6y!&!F|y`|ekW|Y+sXIp9hFh43&y5CvhKkrj6UY&ur7QADL-Lzs@&hHy5 zk>)G@l@ZYD0)X{@4z@%RIx1k_M<$zKvS~Lkae9~+O_J(VCdD5+sd8|J45M=Gx!c?T zA|#MUKu;a;^mL9 zt|Y^7vXAgrJ{8{n$n(U4%yp1d;XfzAwosV6;4tlPv!}n;U6;I^N9g{$F6G-huH4Ws zM0BsVQ;}bCj0_Tl$te<|;N{H%x(H%uidGT0UBm%RKvB%EKgE;|wRVy!5H{O;Skqo>cfRHl%5a-yhG#xsXzdUT>w`y)9QbiI9Y>X2|XQQNzhtD1_GXbABk~-W@E=zUV*X zyT|H;FhHSJ6f;a-NICnX#Qm?I(S@m~5&XjGB9{5rj`FWSq)Uq1l9PmjEbKLRqAH!; zxWi1xZ!^x?m~kE_hUtTB(yTi)sPGa10#{8A9)WLi0`N{K*Cb4ygwF1FcK3QRg#!-n z?r1JWH16TgTt~!;Rb&P0OB#i7!p)nx#3>ytZF`& z5#u(@&6ZE1)?#$m(HENw&@RPUrq?Mm6&?a}EL#~N5aiI{NaU?YZQsEU+KMQ(Wi$=% zH|fr@g$apF0p_6!(vASd%utf-gKDeQPz!%hp1{US1-D*X znWbeT$tIh+$fj#PR2DTx)42wvW=5Cr2dg5x@beE_hjKLEc+EOAA*;{cEM?M`nC2Cpi z7cn||%v(v91=qjz$?`sz>sb&HFxE+ZVkWBVVx`n0yk`HuAQG(?3lMqL3lQ?G0LUX9 z#+|45zNi8&rs3MSm1U=_QdV3?VCY!RbrDb=4BE+EQhuCBwgbnx_iOnD>>e1p$XJ@X&n@upqX|fmD!b4o3!sKbO-Yj1=qCOb4 z03PE%N{P^f&XX*bPxkb+3pYKmWi5-dFMO4YCt#1`NXwbyQ-zjC7=@v z-k{gRdCL;KC|>%D@8hY;&Mlp`kJ)D6@3P((VT9YiIo?R=Tnw(gbhFlvXrd(E3_Xs;8h#$& zuM?-lvhCV|6@}l6XZq{6p(?(usrlx+&NycAT__xJ=0%PRgcnHyJPv>$qjv*sv-J2% z;g5EAkB**PCRwX<^HuN6OdXZHD)_LW;JA7a@&Z#k6==RF(l=dF1Gak;eoJDe5$SQ5 zBvsYIB!4;50*iSd5cv!@W@>m=%T+3F+*BhG>_ zr>lz0JY-$EOt+Q_2_&=cmcdusDk$ZR&CS&Ebct7~i{;)B6{>R0#d8-xgQF)qk#*!er zvfSd_>BE9v{SJwDXPn)Rel}l&Xl~x1DjxK4RJ_1!SMWRS&UZ5Zv}vFh-TKE8ABDT+ zmuxA9>Nf}G)GkA2yyE{gM9~8h7bOsrxFK0=`0sTC?1jJ#O$21k$>5X8JqO=CI$b{r zzje^!65c&2UCOB8<7#vCT<*U;WA6fycb!cN^t;qa_!W6=W_wIo z%$+`0dG)ooE^q6Re&5LHO5kGPej-AQnyzwh(uGZFnL0GtIH(WqTd_gS0znK zx39zu-i1~t*jJzpHc2FHefrS(!zX`ukB{Z469xC-iMu0@!~UaPWh#U2Uy|F8x0e+R zClqm74^iYBy@OW2cH`*M#2nf4F}fAGQnI?!{^IleqD42e5-MgV4h3tzVdeqisDRA` za7zbZmH(O$6#oG?tVm$xCz#NAf2oVMlh8te*J-bwI|LnoSE~_HM!@vPDtCO>{rB8ZGy$E zVUByySB}LDfwc8+-`f{%In-R+CZ%&kOEC>du*g3}kZ51bYs5)ncwtI?;7)n=9-0E& z(_dIzfFZDe@GsH;&CO24_V4yvtoe}$AETR1qw%ir;nI&yFZ(`~`zz6fV@%l#=%C)q zE*Msv`xDck%jr|&fiPF+3`h$x_}EbN3T2t|`?ndb?iWqBo!&&g??k5QA)&G}i>OKX z(C2uBt3f)s3zypYB>cxEw>@e|=WUG0XMFuyRkdVamsl80`j7?v<34rL)O9!Dqp48E zpp76ar)J=J2?5uH?hxRy)CCT$9%;N$y7Z&RQ=!w6@y32@U?ZVg&sbT{dMJz%MEX#4 z%tb}{2wJ=FdR)Gr?2>@?Yp+g9lyq!dO|6tTx~4cj#X8?HrmGdl>==vZAuibFl_0`l zO12FYaXk(C#-V|g^t6GB-psUMx6i9O?j!0kG}ctD1(Aanm?{E|&I->shoqjcL#DN- zxGU($PiaMys2xdICy~u!zVPskV$uCi`Y2EwP9gQ(_WyM;@1-!Rbz@{%%zm88^sjk# z_m0C#%fG(pjGTUNny!XQS?Vvj0$b2S=!2RsBJ{nCq>1O@;}6hYa>w@-_<)KzYb2w_ zET~(}NcPDl2i_uGm4y@-J>6& z-$6n`@#xFjU} z_I|gBNhKs&=26+c!D~=9px55-9pT=)AyYLnO+fccF+cDve@)CsaQ8-{ZWsSi#-dV2 zc%hrl4wRLsC$Qg{li~~2kO?hxXki<*FB;4}JB5q~3=}|$*1tMZV6X7c?Fb025diwA z4?DeuR6%ZF&Z+*Z7E~*3i51d^tXH^Duj<+0=jHxHaMzU2BkG+yYVlE(9cLXXn!+W+{_+gOslY9= z)9aqwyYgFd*LRyh5u3B^;pc%=yMV-B$Dh7?L|bXq8HaRBRes*8F7O~_E=2K;0+JI` zjlsuoxo~M4+6-pK|L$dpx^%n%S55bpMxq1CaJ{m(>0*+@@~O4L=H!~E9=*e2e*vzs zo2N6kU*E2FB(~zZ;eB&Eg<`JMEDb61eiT$WODWr4F&RF*p@%b@8R*F=WPLWh%g>2b z^ule8q)Mk7J*NHi`AE#G;1moR79Pv9~1cAfIwztC;CyR-BzDQ1b00=}$^i zllef<4-{}y`X*9(%d7s^>>Gd$OPz!;th);!^3{P01cnq4ejwemaJmWTO9S9W(RWGe zyt?CLTWn#)FziQTyK0)3+siPfdc8-ng?mU&&+M1R{U?X(Q#%nq({yOb@z1?`i9ePy z-Ya%sn7;*wFm2q!oR_MyJ?1@vyy1yX@{tAy3Tmn^`0)f^%{Gm8ABp`=@s*1u6V;wG z$;X;AiMkRyj}zyHy1QT%86&h;GLD(+5E6hmXD7qO49oxU-wplW#=RQI=)y$pX0=PW zx`&D!TJMz@^}Q!|wo^%LGaP^vFvjja`phsQf+lKx=C02QOq_Sj{|>ttzjM!pxc*Xs zuZu=HT!^gEh%SZWcFCCp$1=$w!N#)?G&5cc%RlU&|HezULDcXgpxX%ioo|P0IV9`s zjFovcZ0zaYC#RC&S8FVH+izLS(t?xz>|3je46UbjO6#(-+qCC#+0wr_FzcN*Qe^CZvG2H>AkUvNUNkG|8p}AG}9VLzP8dKYpYb_$i;DbPFv28n&oB_f6 zldB7firibVbd}YpKXWbwEG(z$88v~1Lkq1gUR8did0E9ylG=29bOoDIY?}0%7(eL# zcbp*jZ-vJKtnh%3R~id7f-T_g=@v4Oz%gUIba>#A^iytId0)f#uq^xPWbNK1_xsq% zLRIO=wd3_9WlJ$6(vj}vAL&PP2-Le{{(?7(W~ok08e2Fl<6)Hm!l;~Ev3ZAke80WR z^rhtlP#S+is|0EXccayFH3;!C1`Dou`L_I&vECR6~lp(kM#fqg?gFM=Lc;XM^RQUE4i-8#qC&i=8+iE#`njCzbq1u5mS zT+bF8-oEmhH%#tv8MIz!9WlQJXR%1|j8+k4Mk+<%cm0gPuR(^gDDi{~ztNm}_=Zhn zXk3kKlftKp%sgf|`i9vJh}ZzaU#fwS)c--BHI?ZoP?fC+kSwxb9+@Bz?m^bVSD_(; zq{EpU{+2YHo2tsHwnK0ZVl=SCb;kT;?#yBxt}%dk{YemV_q4Fw1u{SSL1SYNPPe9f z*$R7fe?hoeCj$E2$NBTfhHXA>&eJqbz0J1e-uzw7`0RkX_&65R6|P-^ZtDv@m8yBV z`%|Y9chHw8iRNdb=FRvXJ4-p6o!h2n_9)zW2^Ou0+nzi_AmSC+W=V7FGRp%e7H}QY zWhNl2xZFBd7uhfXiUo)D%uYdF_av+JIRkO!SZS0l-g3I|;-~6F- zN+iWXMF!>|shmPs7%%0B!9sWA4lX}}HVY)m%F5K6XY$qd_>Qf9Q#+5}n`)I`+h0`9 zPc{YME(n261jBy9;6(M>-+bO%V0Sw|2=-}z%l-oA6l8ZHb@}j8y4-3znEMuLiCgq8 zj3x3mm|uMZcfHl-&d*ZpH?|46G*SfT-&fN23u`~pFFk(M=q@Fev=0r0M_H#!1RMi$%e2NvUGnraX5Z) zoi3Q^=}@DTp;~kT%YcDc8%)I-dZe-v&+c#i?Py}|8y@SRCO3c5JfV;xrBw}?&#p*5 zU|)#_Z`|b+Fdd=Fn>P7#{V)0YBKBy{Knqr`xC<^lV8_g zYIX;;YLIG0L=*G){v1bCjluIKs?`DLdhihVDTqcMpu+$X7L+~!yY`0l_48Lik69r6 z2pwZ4L1cMa%yC!C4Jw?$_eNuPt$ z{Csj|+%h_0>XN(hC3CHccs`Z1Ukah}w-Ey<4S0syD@y#u$I-u$u8nfmUa0VX6$GFZ zvA~D=mz4=48eLW<72x%n4W*oK2~8Xx#&rwmHGQ>I4Y{{hPpfRC0gqAT<-l@DT|?#e z)->|~<;?V`G|N3Ch-EHh^<7-or_jQqF0EY3!rFnygI9iWtKCAN^FJbO*YqK+31N#v3erpdxS7QJ_;ldJ0`K%<7zX#O%uSb7rb98$l>AzIL90o*GZc zO1Ed9J}rX?eST|XmEAtBVyNChx8(E;{5(qz12lgFDb--#%1;ouZe-%nu0%}zodjohcpfKK0h3#Ym~ zzIk*%=sHr>D`(opN-(sH4un~9LT8TnmPQ}4zIIBV8dA_)M`9`+aaBxL6NMS2k+_dg%7cK(} zqgpN(YjSIbp^g=8UJ*J56G{RK26s4~H0_68o)`arppS?m`{m>R?;Ey5VY9O@GHZsHi?G(a~&;x%<4VHp8#-&Vux%^WH*R_?Xj8l1`mmE7OHu=Q&e0;2(~;A?h3F!+2G7-FJFaeR1$ zNoaZ`td1_k=m*;WF`O@VDQO5>E98X0AeyI%V!_}m-6rmFDyf*7g$a8O7Y106c2VR( z>7HrfWngP7)Kjr$x62-fDrGE%wx}!apBLT8$mP z-C1Yp>AV}B1aKF}c|Li8HMs@ER9g7sF>!>v286pb+5)>q*cr#d8fV5FyS)yV~fS%aLtZnI+H9=)R z7ZkLRH&>%?=obVbIIu#J8t#EOK|^q~VW79WD}_NtD9aFo{=uFF$-96F_}Gr%ROn~u zgz>M4AXWvrrvQRktuMuqUaPHQ`YBVpSzK#J;w1J9CiYFgPT$6rH~)4HBE%da_h>qA zr$!`dyw6*=XIl07`Z_^gIa3C~h&`H3$NX_z=P#}y(N+bgq(*wXT z-nl&Iab@XgBdmE@2&W7i8Q6RVuD zo@dqj-^i#3H~eeA-|9gdlSr6+U32&IwK>!go=A$ym$?bVohAwn!FzD84e>tzp!9*>t zK=bkHW9?7;z&6@2)4NDX8LVttr zCEqU|UZYtirVtW>ZWr3$%w8Y-;00; z_#R%Xys2iipIb@DzInBU`#t;OOXV*T@7&pbRA_5gy?oLh!>^-Jr?HRB&B@3v{FJ{b z*f%+(>Tke-=BZhFY3Pl2Wvs#S<@pK;EvSpN5q!=N&>3)ahgCDoUUuNQJ=fHVw8GnZ>Wg?n}*_NxtQmmW(tsK(paLvme z8?$6da@iF$(O==#zPr*sZjWSrLD73|f=)0ka@F=S94sf_Bpt_56w|HIhWN-Tc5@D3;T z#53}lWAo$n{98pe#+)}{V=7gsod}}-MvLw_-ixf|xoNnYlAC>4_t?rz;=PfGydH?0 zVdBZ^xeG!<@fR7tv%(iB z+M-gOdJ_T|knuxNdEVqZs&FTV?F8a0Jf?IaUvnSUXs|}LRdlM9wXDR{uU9ZUt7m4G7oYw&>9mq^I57HDm5r-hGgnZfsKV>=^}IeW*uC?8F1+lUqHH2^AU|1Bw)0} zErL2)KewQBe!93O^iuSg3c&}M`L&KalgJ_IMTD0{drwgbQ86aG{bq7VTU7z2EBX{iR2D9Yn6(!WqH+;^q~3MIQycJ2*mp<$e8FUS#w(VEd- z5WPs^fd;K}PR9S|oUkN{>D5|;N9-2x>ojKA$$U`P&5!e;j1 z4#^xdaot&8k=1pnE5)qmeSG7ai+OXf*H?N~%gdFj|rHA`t}j5%^vd5WJm z&@1y*{eop4rcVhY7I_+8Tc6&dq#f)#;7Etw6Ll@Z3%d$n6EWA{dqREF8^$d%uOGTI ztnzLTrnpnM1b>?|_r4F&F=Mpk?CfRZ`E^^`M zqm?gt!KxvG61E^`PHLuQn16L4=!uVNz=E|y7-VyxhOJxVV%}(I>{1qxh`@EX5&yxo zB+95{J}{dxWUI#DbM?Fsn7vU8cHuBh=#j?oKl21x4lL0~O(#chOO^hlS!^(+!8BL{ zK54%S8WqUn+4Ki3USQ(d#oIk_b5->uHsmKWm&y>}AzzbZjK3{C3bd!S9^rUTpq6Z8 z9U}0Xj@c7g6U^~4NpcT0C$T*(9Q{|H=SmSrq?MBt3um;XkZbliW!`WMlOxrKIe%aM zp^s#J3FW@Uu@h19YFT=B|AH*Id{AJE4IOh%7!bgt%gXA z`e35gAG0;;6?$L&H*Bc?M7jJ+oBH(CQ>c>*{ie_r+g(OX#y2dL1gRIz8s`OYX@DnQ zDG|P9H*brp?%3kAr#Lm*3N5(aD>QQ-fCacfQ=&S@u7BNlUo^t_{oh?w&LuE&nkx9b zLcw$V)*oP=EI7C&Wli;hIt=Xv>KU~s6)mNV;^N0SHDd0P?Ny6l zY&zu2a%#yCry&+Y&NEYRter^5c~Qu)V75u&Yps%GRUjLQHc5vv_qYGP@EX-(`O+tP zL&_SonYp4-7519!Eb95%WrlNX>erzn5>~Gl6ZO27^}PE{CR&}&uZ)dSwA3y9e@}{} zW3s=glszs{$#wm`6dc1N?mv$I?`!38b88=vcqzC>FvY;-Z~5E9!Z5Y~CZz3hVNhRm zyWjGnoe+yr*IO~u-|7vD*%AkHCuPQEz9YrglhaWCU!h;R@>hT89K5ucFf(i!&2ND_ zR8>SC|0HcD3gsSu9Cg*V%En2hNyU*7e7;S?kev%5@bD%zN=9~-9JPxy%cgLpj(_(P znj*ev0TOej`|`TtQc#@Eu%M!z(jTCgf9Y!|Bu<9D+!#?TJvq;}InEn-c&wS5Z)-y2 z1G${+K8KGo9VL(3=*=`bRh|)k!&jts8I5)-?uz#cS1@ESQyo(E!;&cacq)2b=T>E3 zzS|#mags=4)a^>n)-sKGTe5E3R>Sut^zRc0Fy!CJ{pDI2fSqH4%jKEcHcK=U+s-^V}} z(}YsPKwB_vo^Hg03TefH&*gst9XsHdiO&&Q{`O24i~m7dZ>MM*`S-_R>3&80kt+A2 zJ-lWLv=B3Y#M+d1abLUW?mb{M6L`7XFN)^_#T2C2$XuOX&~kN>$o)2a)N zKPr=PQEZ1!n`Qm6S#!EXp3?PS;KX+kjnx{D5vkzOcxcvie3kA&6Zrs%e@Aq^DE-OA z^P&nbgEX=+;YYnbfBtdC)d(n3s7D30$|RRc9bp;@(n(h7_Hr(_0QY*WLhQ=%XIj7N zO3&R~>=g|X)kjbX^3ZJYsz5%X@D!(v2!BYPW zb1-%_5?+yuUT8!42X7nWUvI!sbR;rXF4R%yag?$N*obr1Z4!h&4TGm0KNpECpbh=H z#VS)ggwtRJ;EL>n9YY=aO8y+C?lPRZ^@={FyYxr3%)Wm@9B4S-a&{2GrE95z+QCAk zY)4RY?U)A@xDbtc61Nlv5J+YLpbIui9D=P3psat+(9$W&07hjfh$Vz%R_>vqkXC8p zwTkxf)*x3QcH(y%nrbBbr^gQ=De4`|XSYotXAWpE-}3GEg}mzqMc&Ghzs5c%f8)u; zw%OR)E7ZpBtSA~9;su-!I{2VGR|fZ5q%{qj@=xRYWF3bMBi5(uPj*%2yV9yf&LxHo zoh`FWS%(=*)j%L);XBAP0IYZVt-t_iE8Bn-8|Y3w0i%E*7O(@bE{o607GW+Uj6W9Xb8uHXgkIix+%0$@n_qcZ=tmtI% z8pqfWKY0?eoX|WSGgaQp>*7cIcZk~f>d@Q0BRdnRK!U}_#C>#`i4~i6Ge$0z?mycYlpU8TbvtyoxdI%XfnyO z3!JBN;+S8slYlCP{Pesx&B+rnaRQygVmLIW)+{%0@SJ>#Le} z?BP`&FX0Ox1bJ#N>%U6r2)%Upn}qXpTz#HdckzoVf|X;O40HJdY!G^TGkN~8&ZB3? zjD$N3qWr{L?Sk|n`MsjOHxiF?MOw9<=>g6B zJOZ}-LL#5l@vd3#wH7Vh{?}&_5&(;)XI=?KG%~>Ov&w~@=5XkmAE^JC zvpja%EIBCR5`>E70OCJWo+WLGZ!nMj4&B?cQS-yu`LPst&X;g5mb)GO&Wq!G$TWWB z%;*WtKI}wish}JCJk`{YI_~VcZ0IM^PF+QqXu$d?4)X1tte81NuKE69hu51Y7WLG! zLO45ZanA1r1?5>sZbZ7;x?J$LB`m#zRq`MwTV9XjWW@TSmM<71xy}hraU>7v8QaE= z&JnLso>5NE!k_=#e4IqGFOBNqR;1!Lt7}F-(hf2Sxx7zmF0{hB-T#(zU0w*~Uc0wm zYaL0;Srne7#h%Q3+^EH$&doAzb%~KW*cO~jd4qdV7;pUPtN#iHLa@j!3A=vQ9r&nW zaxr5<{`b5fgQ7VJcdmL)y$I(+M9M~R71}aN^E`LRhQaUVdOg`19L2^=OgeQWigh1H z8B8_$I2kg2UI6rgKn9?*6rOceVc&}Wlh6Q$RKR_R86{^!8<)rfvf70#Y%Q1vU-u9A zjn}xJ7*5VxOq(O^T{BL6TYK${-9JY4L;`04+nt=dQISbzcIfx7y?ay3dc#P7SyHlE6}2iNa;U@yBlN*6f{5h=1) z$8u}XSDUW+=eusr|KsPxCh-{0%^%NLoJ^<1A6`rY4#s87^0Um!?$&aC7@;^}KoEKB zQ{gR)FA|}sw+fihNvl#<$*fuSKsO7bNK7Jc77MhC}gLH)e3(9Gez#m^NY zq4*61DMMc??rw$84C&oB_$8&G_btNNei(!y+b87-j{-aABoF&_y9dHij$~OOl6edfgh?UnhS{bjeG=hQ@br3Ml6OPyn)m@Mjw=I~E(JEMB(a%kyK= z!=dI=i~57@>NHD@*(uD%hNmBaPop^vGouStxK~wT(N`OOpPfH_Ns>Ae^*lr>OHP4a zIUbXrg*@&SzYEnN^kAF|>8L;}M=aehXMbnSCQ_ofqJR2Daqy(i^eKqn@+zcAM+8>8dY=5bTW2?u_z7E%gL(o!YzbO z7zs~Pt^Xklp~}nscTF+;&waN2?1_33czmBVK~P~#aeL8Sr)pYV)ov()+Mzwaiv^J+ z5x=(}1zjdXsvd=}^Jz-qNYHwHtueZKP}5kr@SIzq{N(KlRmw!+O5#V5XE*`))8eev zl@e^5pl$mm`waJ6CC;ua1IW=h7}qz)K-bs9`t3|FAvckf{Zf1gqp|L568^pP%~TUX zgw0-}BjhyVq%35o|EYTr=z$YY@)CGGih6Yb?J|(7;Mq8<@%*gY&|&;xnHYG4NrhA< z^O&6v_;tIL%U!6JYwCDNTR4kBVnho&i^Ds1Ej!1%&$Q>E(>xDtf?%GoBnRooNbPBF z(GW-4<=saa`HAmaJsS$dalZ{eI0%z}U>obCS9HI|-g2A3!oyZpZ zLwNNW5G0HZ3j~25Yoj9-WO`G%Nhkpx`tzEM_&-o`whBA`bH0|}{nI@( z?!1RzC2>x3TI>!3Vq}2j zcMm_?Yn2WgIQ}V1I(9U1fRo&d-_Scbl{qEl#MwVEdYjm=_~6{fSf?C=p};6GX0QqQ z{bT*Hiw-60Pue9B)tN0iS@1WP6cqfo50D+_6KrqHc|;6H^dU4yzlhR&1 z62h%@%d3ld?{z(H7kobay~)Q)y-{_$KbfrXq4|97W3^|R1b6ZQk5+vg3G`0ueN2db zQhuU7;e0v8_V=+35GeO!?J5Pvb~j0@9K5y#C*g(TSa!qAM2VhE77}n4WU8kxSwB%3 zqEGHyj<*@at>YW(n!4Tus&Q3sWMZ zSOCG{cR9chLwYVC*`X%`KGB{`2gMM0DO7#lb}SNzs~rw9K3Qqck3e}R2cg+zsJMuku7$|{4SU)3xVasR zj4bC*iaIK{g`s_~QsatzvKal_s?BTU{q6RW{|@&Cy&GfZm$YwcEIk?R_A}oSG{do< z*~r7sg7i2#j6{0nWeJFMN)B^Vh|yi$%QS;5&-s}!>JjM2p>6Q4v2Wq3a!u%jmKD@u z({*-V6N`uJBvHs?JMc8_4o`n=b6yiBmphaHVIAqFJN^Ac5kNyfjZ0nS@Fci{bw|B5 zgxW=)Ro#|o*POLbQ*E%PSmKlQpL(Qz7WwrZ5Ben`+U7&tyc3!Y2hR$6Fa7I!F8*sm z>;Es`Lx9VE8T?;M#azhLn{PUtfMlGra@9-4@J7`CTE|MSg_@qrtQ>2N$|;T9F?5Rt zCdf{WvTZ3got-gcaR_Cg6dCNTP)=`%tn+%%w6nS z_#*JMf#|0z<_ge6+c!Apl=4~m+)${;sO=xBczP&HA1szxme58SDIqBJ`w1)NBurxz zP#Oj|E<&?NzetX7v`Vl9fF6Y8>AO^SEva}G!&v*IB;O7GRwKp!YAApkdR)Cl0!gf6n)k_z z&l_)at%9f2M`{(e8$S^KTPdBf$ae{_7ajTlj!FF1+a2((WSBj7soA?=*_Sc4X*e~g zQ5V&z;Sb!&;m>=V*Du$9b(WZ9?R*YJaARx|IcKaCwy8UMUwc1iQAK|AU{&Ahg;Ok8<(*%;17Kcc+D{OnkS@eG@Xw9iH^zQUq2A_SVIgFqm z-c8~+uUiLg>AWNT`11{lIdt{~BnVvWBC1SqmFZmc{_`W^w^psRj`(#IkARo2WoFnT zujH%$A{4ZKv2Dy&HVozxc5t&x~Y~v@=6hX4kk&6$-pCIvjW*h{$S|qx7af~Fz+Fx z+kvqzO>u%;eta?BhIDbjP8cs>l98Ip7vanJHWo;a3FxzeoF)Mwq`ah<0RP?-$c7+( z2Cf4gn+qde)F4afhbKrYG}LwL@_1sQ4(gHY+g6jNwpQ-UD!cUuHS5G%XQfqIQTmo+ z@oBL590V%49#J$1?*7o}eaz=a-3Oc7#AR7KR{ecV5DYI*AxO1!l10y?*%wH1xrJMM zOB6k>mn*1CRrl?+NK#~X$_cNQ9NRWcL=0Xt7iQ|TMjIuie=3+$pKg;biEU7uc@RVS z%0}PvV_JO@ksZUg?bE+l0zqctXVVoR<0B(RMSWJ8uoDras@ULFcr}1LXnJ7p4KL|k z89QpLwg#56wo%6YSQ;jvJU;bL*LkD{;#|a`66%+NC~hE3wn|+19uQ)YsJbBE99JDB zUPOfryBigAyENq&6g=&GG8_63(10U@WG;CJ62@b-Qs=QU=_%C233B@v1TTMT717sf zbfcTm=y0yL*6U8cqJ08^DT1g4hpe|K2j9 zSKmMmH#^S%`OF`o%&n=5>{fSLdBEQT zL|gK%zeoZeYUC^I;W^%O`1?qyw8ehFff&9*`Hj-%)%-Mr??F-cJ5`NIUW{H6GJT?? zFP~IK?E9piyEAJ}K}rn7VxVr*AQDA3T1dHpdQ3>TcGh%^S-LW}e0Q$NBrW3BmeR{& zyItdjYm*6QNlU*1SHgMVp!8sqEPJFy;{8>Fl+uewkArlDX%AfOmODu8-A316HJJ_; z8E>n9?m53>pwQ9#aNm~G;crC#b=*DUw|x5^56XLH>14d>+}oA=t$ugJ>xDujw($?R z`8jzAXmF~sS%rYQ--fvRJ)H0X z1bCmJPd>YYK>-V|H_JE-g$HT`(XN+9Li<~)N;cuY3NyhpP92>fSJaD5mpjmWVU}~+ z@FW$(X=&-^zU;tK>J?lL;^D3QGZm8^X(!vz0w40oT;N5)|3@?S+TCUTTI!HJZ7AfO zqQLxR4EQ6KIs_r1In$TfMIvheze(ejI~f27>a~xhQ$x1nwR%o~g6{%fM}lXslMXp1 zDsp&g;|eD+0g4;|N;PLE#Ecqb;EmJZnE;K47EbIQz@=6m13n+-yKfm-XuY2!!c{GP zA*EcQ1eoJ)9QU#gP86%VPXEcgw|zSgmm=_E$x_jN`$dZPU~1@TohH$DJUBENX?wcJ z2R?2}A=V2`9pEE5z9AK^u#~7{`>;^0iP3T6LQ;C^*{a*rljjsl;8P%dR!KDbBUsbWNSmq(;2Cvax@ z5LkVF8VJM+8#-h~KZAoN=n6}!XHJ67qIUlINeS*De}d}K{p}^J+VL=3SbRD>vX(#0 zko)K#s(fR2^HKr|9U`=H>nv9!Z3~C;k9DGmkGy&Ajq5> zOMioe6WkvVAbK7g4KS$hxGV84m4%0U1o|I49HtFR^7q>DJ79M`fG7~?nt%8 zA3Z4i@dTv``}I}v6&q?vSXYV{0)^%14vO!D&!c2JCNY2L$kw5JgvfezCf)0mk8*yY z6V{&)cU=EKkZ4nEzDLtXq^5mhqKM+@TaU2Oc@C$ddv-pj{vyJ7sbWJQ`54+GaJak0 zrsO$qftjG*IWC$$ES-`Y0dw4l85PbA*f_lcRe_3jq!zOl-hXf!g!^GVHeAja&S|R= zzS6!)`2iW%>tdgsVNeo2^9lJ^xeq$4T|8K_h+0{`V8(g}k6x9+9>C^z(Wu|P1oK;~ z*4qbs9c<$0_j?t%DjR;KrJU*Y(ekX;OE%n0P0^XpMQ&T$xBVSkoN+4Vh!2m>|M{0_ ziO6(_urZJjWrp8ylVHUGlSshGBnX5b74ybxu;1Su-aK5NA3p|rEYFzC|8ub)aODx= z(h3jliQ9YvPdbY(1WG^fhEmT$dXC}z4CbXmp_K~;**6l!E1tqk7;7>C@TP#rsG4Pv zA@Xkjtl3R93JLon44+FbWq*m;E&Kd<4(`uhQCf0sNn!EBYJ5YYRe|e`;R5O$k5zL# zrb=PFfbd)X&Cdwo5iXu2s)lNu++`kkaRjYb=D7arbeOo(Js|i|WOSsgjmjHOazI-9 zd|X&bpDDf1C?8_It|k+9mr{S0`PNo%$LquNg~y~j zH7Thwj_5sbgPhM33LNTH#tk_PQuJ#e=7d@a6pk@jO?75#U+dPc9GTtTrSFe-NrJg} zWRcT@=rF68n;qr?AQu)wR%++F@Zfx(&Wl>}@t1T*HeSW*b{{swSjb(=iZN6pFvbRk zi)J$JEN8;P(gOoZSba3A(jD%^!BLdHV<-QR)qx)X=mG(tKVaDj=gR=}p|KI+bO6gj z0B}E@E&J4efUM z1Izm5asZ zpeGm<#f4!4!?WkQ4r3~+TI&-8^Ot_c+8$u_oqz$ia2#+81A}5zWWclWJTV5Ql0Y;{ z8NO`v?rsD4db6i?V8<#%WHpn{+^CGrpXd0URm{~Qw7p0BMoKEJ#gsk>2CG%pl#EWy zFxw{i>Tjog6=(Lo1ww!qQM$y1kLd+`1S(95d&^Via zop;|#BcT9qFHTFR!mj!$D+#pPtXjPdKF1rj>0Yr(B6popwUvsGcD52RC48%AXp%9; zhe$p^Pgov6+aZQx|A3yi84pWd|7hL&4lsd6KPT{{a=v}Gp2(#tIxb(IgTWKTsABT? z7~Cf%2J4!`ff4^euxHyA;BdIhtNcVx402;rX%RDEZUO?|Hu_OD^u#`V*jX~ zmX!CR)Vi?^OUu+0iK~?{`Y1O^6jaa%Jm()gdT!ydHagp!D!|HQ1F%g1B{@)t0KQfQ zeEpkUSV$J(R@m`qU!ZRJo^4hBwtn+T7jEt6eo7lB zy|K9L=(XF~WOY}7Gq9L+M^Kx7{IT+5ZK3x;z$~L?=r3uv!2|NG{T~80p`fE!`CH-J zNu3I3wsxABu!zeX=pKENiYxh~Q(J^hjc5)JN=;?%eVs|0GS3E4|0|hDf&s#8R#`uU z2~<~Fz}Sj~DlaS3X3hBq2=je@%3X*sP~_wQw;;V8#@3k6@dWAGm+HFv5oTAe+|ji* zTi6SbCLek72=(q2bifSOI{V1tkz9Bt2M@4Ablh9l$HUHGmwM;y=!&Js6y8i-W-)!!VU{JHv5xl)+zl^_Z5m62%{X*Z7M1HS1x$@=)+ zjJ41we=vKKKsMkxIw6L#=A`v>kmrUrI(Pt{4(g=sm{;Ua9+vMiM>T0j->Phtk^W>2 zRNh^lATS)Uud&fu6~Pgj_vE?@RNuyCu7UFwidIrS#*8Cu#mcAW z^`wtY7aWZzif5;_aO@z2Qi?sP2QlgEvx$+Dr;ArJ`EV=t0h=1=weYLzi_(j-UzWkO z@%J{#w?`6}@AqAET+KfCIW)-?ZMB^S{SCejf7CzsFbn{5O}3m+%NkXp30_IRYgfMP zsOrzm!d{vpCK+ce_A-o(o8tYJeS)qviL8MVDHk?r!uy&YKOzzcWa5?riq`jEyVZU- zxcjjiMAbH{7olCjTNtlF>T@i?P+BQ+|Hqyl>Q{N<{L)PZ{4Je7E*i8QNeAVPO_uhm zJ_}nWz&(;@&0H{JG;~GXNc17gy1aCBow!_@H@Z^B=r(sq;O6VBwU3pTf#HVXa~lzR zQi3LPTlgm|7OyPbe(}*>8vH0+`uuBtGa%{By0(H5#St5kXc>ajQJWckG^vwuhs@7Z zgcr)dj@m@_%fJ5(R1kzql3SIs`ElbYj&&}Z5i zp%Zpg20PgCTvHTDzlGhc@BV}MA~n3y3A_o6A7IlpNMq?I>O}eebDJ@}^T+acx=!f% z%bd*AkHknX1RCML4OVyl1-&^4s5>m&{pBk6jktlX%2aMFqV`vVm*Qm&%GK;9s&`#d}mJ-v%%cwi{eBinI7X5Q0OqRql4Fapd^&YV3iKe`c@!I0g z!S5vc;-HY{-`G$x33FrFU+YVBhK=YCl>`W*_Lr!Vk%5mrn8ZIVvoS4D zA~r{t#g=516PsX#iLdc?8eiH@qx>AARH5 zv_l5O0u|$DD}2Pq>VaonLwBXtxV!GsygIAhR(Hw9OpB-s_w9NZj<$5K!1_Kchu7zm zOfiu0pzBOr3710XhqxxRn5%d;3l!wve`RM@kM4qlo@6Qf34$KaORly4kzt?i{*df8 zD}8gtEGP;xHDf?bT;G=?j{G@D{&pHX*M9hOsxe&Z$`gk&x?(J&)38xbyrLMlj2tG` zX;eT25pF0$vD5*V6F}@g<>@n@Wr28 zlO^F+tw2c^F!(mjay5Ya;2@x$plx0M)~^-RusXL_d;`BeO_$Dc|>TkDq8<$8crhnuD8c|6Vfm4$}~Y zIj%xgF^%!1u@$f8U#2$-Ko!(^c>{-k^qd5W9{`aAR6*8&9UB`}7P$Wz@%2K$#9QMW zf-7mR{8M_0qjju%;$6_{pz*Pti>sh!4d#zmaJ`Mvt9`Ce4DAow#5JS46s?eh&rsj3 zx8El0=`xd+oRY0rRM?QZ0Moj^Hhso%jkIRbJ0kVWU~u{JFv#72x)5}_9#mL*j8z}N zR#|f_6#Liki^wSLZ%^mnZ~;lf=xLlP{n7CmLbXQAfr?nUw;7i7X8|weRn$za+a!D6 z3Ifqzu^X~{lbl3!D9C^he*r`dS-3qHIaM`CoBqDf74TJ2M!ECAv0%igMfBEIy0{_N z%26t?ppWJ!+&}W+W~ja>D`~+bKx&OkPm*A7-6$&ZTjs;3gC`0G?D-WH(*$OU z>VBX!JxZ#GfPaR!8WLMM~EuA;uGKhfQGw4diuSh>_M{Jv%nm!F}OfFEC|&lV=l6E6lKHeQ8! zm+kkzFZO#03|+kN51VD0b!hJlO4420?4B$vPH1?=GV=YaM%l7%4t~&YKxV$;XfW5D z_ak7>$=yn3OMsI~Mk(>5GxM3|1+5C*w)mnPl0BWgBU)dY6;YH{DdJ@u<^q^CoP9Xx z_AwDie6;>w*{q20+!X){7yyZpt)PUDAEjg?dUN>%3JrP;o_XUbq&~v9Qr+*phcz_O zKIFVS`X~+VAa4DLos}IVdJf0g^jM-B5I!5($$Na)+iG*Vp{{;CtOR}^Vc}u8(tn$8 zGQ!;J53?```;ZlLmiwrfB7C%6b)k6*sqx6cKb1l2n+ur{%QM5n{M>v^n`Lx)LS=lz zN0j^b=HJAWfJOG*)zeHQ&G99^o3RLy$WvcuQh@XJS493-0)CLN=x5(h(O(^2LM%)q zI-NFPu|$Cf#NI#u4vZ+RhKVPT7U0zM*d6z3P7iTtpsx$%KJwd^D3}q3g0{Utb9tc6 zEO5)5cc`mD|GmCz|5f|qxFVr@@eNNVqQprmo?RIs5Ms&$GYf*4g!G4!Z?YLOFC5E2 z(6V?J4iB^#)f5PV)aJ$nlqLm zN%jr`%V6<sB@I7bN}T2-G_= zW)L2v4JhZp3~um9Xq<~x!2zgXcm1*y`cF*1F36$j-kle#W{mu!_GRn)7n*(Pm*NC9 z1H*#g8?<-K;X!YQ<(>SNtS!4%le_R#!KGBz6&@(?dSy#?tsO@P;;~Dn2j@j(ySA)y# zsNV_uEJEs|mj7ff)r@g@YU!Rh6|fpYi7q*}1O1lx%nAv|zj6(_o;XHfmAW(02Zv3@ zmJIYDAYNnpwh=JL`1Jx=vX?+Rd$)~+I)EtCYtG5?zk#9wD-!{RoE%=Nq840wEEGhw z067lS^84D6yTU%lzdd3~Bie7ka}Ubw4IOF~z8~5;RXw?Kqg4*gxT7l!D3mD3FJ^ye zHZS|GyHlTn%u%y`uP=GqtIZ_VmMbnC(k{69W^>jtIWLaMbk22RSM?j;#5qgOplowX zm}(_xoPmkZUW=epgZ=Lk4|Wmmctyl;GLt&JfDt(b0u5qOk+lXRux|hLjCWNaM?rk% z0iQr0jZ?r00Y^3C><1z_x-}Ko_Sf4q=~G5KbD*8S@1R)+i#-ihJXw1}EUfA)?!Ukb z8%`c`Xciu~y`lbOEMj+wPt+gZTDg=~>dKmgpwqeM(`F19Do1G*f=wc23%=PzF^>)n zX0@KS`44H(|L~AjM{;r_lwW>0sujFwpgQC{XiinkAN+F3ox+w;w8f;bpv6RxP3cbZ z{92Mr0e|FZnO-LqVEcjyWHbWACCX=@U~04`3^e%o;l%56^+ax+!PmJr)7#aCXrqCz zEQ3}ae>Xj8A46aj2k@qp!?sD=!emwtk9@Dc(jHS;K2)8lyJoUK78>_L7nUcxIXYw5 zC=Dn3bx@=d%E$N)*8D(LBQ+IP`jE*(cJ6n#X9;T=%n3gsyaiQVMfkL9lc^M%6*qcS zYAy`fojZ6;6pQqI^r@5hPA}9&Sa8}Y5U^!apHr5YP!NZKJp{08iHc%<(dD=X*J8gr z{iwcHZvM-lBa3Of7AG)e!wBklQr&!J(N5#w2Q!=5(HwDZes8tU5x_(JHN&50pD}3ihgFwE)3vR{o<&rD^-I8FY0 zF#+HTu+c>QUxV?&|EGcN^%deLCzsklZzBx3Bb;1NmjYB`db=GVGXb|p7C&rKb6~OA zf}Unm4l(L3vnu}uzLo#Jr)V&h3$D{aHYPX>?}qqQ&xbwF4_u|LIl_7ki4C{npHzky zHHt_$RePiQKV3;|jd*QCX06W3T+oo8VgpD;YZ!_Tshdx2`)kI{ly(C;9E2HS42^K_#;OA)}LK zAvOa$Db-wCQVAXo!S4fBYMhvwoiFQ_Kjt)>JdM34FfP~kkvhMn@P*{QXb<(Oa}SC% zYa=6OC&dtdagwL|*|xDMT)N#Kf>yvQY}(JaB5J$(G*qP=QhFfQOIxu0kX!vFd{EG< z5&cbMJvL*HaNj2b-*d|V4JHyv^n2~`%9`nD0|w|nzn0ja>D@~OSm8lnh9%>Q`18E# z;>gD0#^^5MHR{3T0EZrjRyvUfNZ?RDqdjT0yJ`M zIvT+88OUAFR0T_h4-!*-oFV>j-n}2 zF4-`GotJ^)E+cn438b_bgTKcQ?%!!%Vk=dc#`&jk-YQ#V-sEcrH1LYs)f>S5@#u`J zP=zX{)bopjg_5pv)u?lXR+TL2iN0hgNT>)4dz#u8inB2G=th?8jj-qMrYSl&=dK_W zUU20qdC#EYyuY0#c==2~{}l#kP06sZ5sPh#X6Xsgs}Lb6)tA3EIxp9DiMj17T(UH# zdE||R0)xP5fB9MtVGc}3RxuaR8;w=EXpZod1wr&@TwIXX&%x3Bzl*VK++ zN%R1Gr<_oul>Ym)29M~Qh+A@+7iw>5S8YbB~s&`5bBHW$&*>W)R z9?k+fko>n@_P(;XGMW0`xcj2T`>=U%98H*QapJ@G51WjMozh?_pXNq#{ro5 zON?4^NVpPD{q5ALqCZ_URwae2=5IeECORvbq+<=o@~RNAy8afvxxRF*5#q@=sLJef_`Xef<9i=!IqHZG^0*=Ne)4l&u0E-(lW@n9 zjyn|S16*4h7Wl|sP#}tgIQdZ2GuFmpzz%}S1tcU4#g{=HF{d}Fmo|4Uv z&*W1ETD zgySWpPa2-@iy9_jJ}gPI>v;$gOKe;C)C6b8k`-!faIP{EGU}1{-%-l*L2|`EOw_ne z;R+ljBX3g)bzk04Sj3sgFr(9f0Dzib*bDH zre|-D9foeJOaNjBO+aCM0d<~o^X255-C`zc)C>LcakJtNHY~oriizx7!ddZXH5aMm zs7lQx{>ES`uus?)nj%c>RVXVDe7Ig`6j*9%7}`OyX^Tf?0itL%BNd^ z%?<||sn*kuXh_Ju!+8_VXHNB^Mex(9xu0aZ2G-i1RKFsZ)xDD+_Ag>wJqp3G?#{PLew6v) zv@R5m^*+Fq4Lkz!f+M$AUl$=_Hp^yyjx70qiqF6`51z(Ji5NjY}Wn9jXb#s_^Q zvK8R2!02MM2KD1V6n+Sw%}Oo+&jWA+pEv^Nd+%U>D`Mc@BMW2P@*I7~*XYgi$vwQJtZly;U(+9o)%zwDcF9L3vi3U#MoI>wXIPsQ%+Zo3B|#QpS6@eW1&vu? z%28{@YSuvFw25t}jn)t`xD1^FEFqHB5BIkqwjEF5Ciec=qBlhy`Aco5Pa5GdCzfw~r;X3{dFKr9_PV_=HBJH~ARqbV z^w19p$jt;;Z(F(vE3cm#+IYqS!pmvohHuyev{C02dIG!g<*RP7c4*3~7q=koX&Ns+ z&6g1+zT-bbba>}-i&x%b*6e3t8Z)RQpfm9OL`Z>;1lLJbhGGzT1MvWW$tKF6-eJ%l zBFKbfnGOpLrC%JQ)Af9At9-XqNnk^~nC`S>zG$>;pziajdOEE3*z`3s@5`Hzc;0c( z(=J)!dD+oc`d=@j_6n&R-KtaEfQgJVsx>z9C5D^lYT*N`)-BF_mb$$4pLKudhL4Lv zQo@!z>}bP(zl?SQHT|hPuuT+E*x^H9a&T2l)any>gIk_7Y8aN@#fsGiy&Vlq}gHF5{@go&GQYmZ`rs#ZW1sOli1Z_adl{MP4IJdum04M)e6`ReTn zYx_5)Af@p54XyMmK6zreCE2+(%YoIR>$g{f3!p~r6`^Wb`_`g8vDuuT5iahI?_{af zb~!c2N1^=shyhSiRU|y%hD3XtLUYNBD@Xg&q^VFU!Jy#v^J82Ip}8`ZS&sIn``5Y_ zMScs#ogUD#TE0M=A(II5r4zyE7#h|M~Gn2`_xEv^ZT`L zPrykx29r_^KwZYeM$Lb3&GP%2U7h;c$a^%95NwBI(C2~YM1}0DW4dP;{}*unyF{m> zz>khge)|S5zqg9b)Cu!nB@Ra%23Gp^rSBagCo;_$-z1^V2_8hF(}SOw#mQ6X14~L^ z!DaN*6@3>x^HC=05a5F+K+3K>XiEqoiUFo~RO@+>nRP~&=<-Bnmgp(-O>~;8J>kh{ z`sNZS5UX@o3~*fGHoANK%LGT4V*ye|4L??<*qgZs-*rBjuD7m^qX0D3O7`aawkO~+nW{z;ZJ3f3Mttb~M;Qjlf9}uyT#z5iuS8-hSUcKTi5k2YB1pwuY zP7B5z?_NBG*w!VqfJXrCFrXcqgHaN@oQbYka^A4Yxf|!qqb?j*1U1A@C7jnrVhIOI z?U)0T1o=nF7S_Nv^5g<}-f#Q3l#A!UU>!q5YHaFQs zxp)1wORmKPit8nCM#2!B+q>i7{o`2TRyGeBO;?2X>_?vH@DC-k-}NM5emjqjZbK(7 zUniTvmXe0X5H^&51yB#%q$#P^Q~#+it{|7Z?Y)Dx2;1HH;e1{XF!Mdm0;oI4S;{3V z;Q*XzwacTA48pqBKED5kZJUUw!#=tBusN~?hC}nA|4IgWjw}gu*$H`;<6eeFHtWzf;6LxuRZ)Fa6C0MxE#|N*@&INyR~A4pLcuoqP`^yNR^rOFw|_toiZ$nX(yE ztZdu1KPK$AJRo6`L_HZ;9{C!g`GFQ0*_Xyoc_iAQRUDk0hP?Ic{ywI#68(3(PN2#V zVvwTEcVKn@qzZETRtN@T0lkOnMF0N?}|D9m_hpbqxHR*-oH)Taw7$x5aI4MpL!%#lCe z0A;lR;?nMJcYlvX-O@@KEv%#_@0l0Hr{}{>rTu)M%4{ZY#Vrt;>1fM)xTpaM_>zBq zc0f_|+?BRa_M$9UDEJdDR>5Qz{=%DfnBQZwNk{{ugVQB@J;6wNoTuqIUD=^u%_d(u z10(nRsa>IpGdQP3w!p(!@Fu$w>X1r@m83G?4~+vt6D*WQ2y~_ZZ_aU$!h`>551^sw zf32Ab`y!}NssN*#OAuBr=X9ld_qPlSHW&241YBEORhGZUhp1z2#qLhT_W~b-4Pti= zo!0xS@#UwHwI-Rkr!9J9J69=>?7H$ZKf-VGp~vy}O`YY`^;C^SbhI|^2g_-OfWLqC zUJE|maXeiKMCmImmyY(ik8e0)2Bn&-Qg!|q z9}QLghs@E|R>lM!fFQ33D9XkHD{g|p?|EcYc6Rp};8H8kyI$e$tXVxf_C)NK=EeWn zFr#$_{s zfm`;8E2DB6o{Yrum$KLNr=h5&8>ihNh0JvjuurAnb1z4jAPLa$b) z&-LkJBW@naOPc70uL0W8+n`eQBqv2e&#N&Q@)r|f(&oz z_esYUs`p2A_7kx9E*YjAoIhIRqSlMWWr7zD4UzOe;nbVKbxjG;q;cJ#(EdpbGwIBf z|0H~nQusgB6BI_3rb2_whp3xwX?b{kn5X!|W1}!0wP0;c({LX=FT}W07IjKY@?|{i z+Q;}U(_Pe->Pe#jkm6`52ITtmcWhAT$otshe-HA)IHVvHXVtq%4-O(;p3|?yWY-!{ zcGKZr1K1#c^?-z79u9`t@Fn}lEa-DT6; zO{~yDBdz?=%kaxdYFx+3*0LWSh~vROtJU%2{6xLm>4WKI`R(}KlVjqfRv-06>&V07 zPzw&|**D*%w1RiQTk})gFYb!UR|wb<9b&1(b@3^bxZT8j>@6k^UT&ojbz4j-ML8#X z>Z=0Z0ZlgKq;C#UPv0q?02j6kbqO-AfGlbt?eCwIgNp>69&ADiOM^xd+7F7#Zk74F z*44FOsvb94Q&JkpqTR6b__bA%kGI*z&%YhJzxX-SDe^ad9vkkQ1(gj*OZC)EGyGIB z(wP~$C$zb0ITFA==$2(L5;1z^LP!{_*|;HWZ`DFYSDWU#a;v{Ay}y>vS-|T=Um?^H z==c0+-sf1((syc8ILX!yg)qg=U{W-NdaGo9n$yubW8`R`SYj{vMsQwVPR2=+73%qQ z@W4EIi;*N2^wU9rFnG|>(E!RTlR|$HVU7e9>(Pda?%h2djl2N@u(=O4nshu>)eTth zD+Iv#z$W*@!6}PC?WcmxCr~M? z(yo)PvxCHY(SH_3(3^+}(Ju}OG1dwq9)^LO-PtjkLDY+7LC7CqtqI$EdviOYEY zw)Ntcfs5o>Q@ITmnWue|`5E({Kjoi=LLCS3-?!x#3LVHf0bf8;E@64{^b{cQ zkkpjsR?DiN@=c<^r4_l(J$u-y>fh(xHN42_Ms?ow{+w;9le>y zyH{5+okrYS@R?N#cG(7z>ylb^+iv*q<5jS|`L9q}ftsj^F->PvHyEP6rR9P8YZTW|2~Bv$45fG zF&)Uv9=QXWz+x~+LW4<3A}7ftdmL-Mfy8BdUDxZixJOo9s|#{AE%eA8)bA~F@$1=I zoj?|&>a-=FtH0kSRnMQf5d%=|2ej%zx37mC8<&yBag@y5*Kyxf-|QP#>b9Hi1DK8I zc^5B_b$Yz>(b*LX+$HY_^6GCJ17H~z(~-;f^IK!YC>FNsY9<;5KK>{o6zOWvUGC#q zY}Omny@mCW{1)6%SEfY4G_q!8?O>50U4$7A{R<>1D5Y1<0gn+%M)G5m?`^t8(P2L; zHHC5>YbvAyf)W7xU5oZ4ZR|vQS92ENDCrSk5dYC>SC*TqM)crl{ zEX6~#`qzS|GY@6~Y6gsxsAp zj=}3oETr%+|8s##As~UuWy>SUN_^ks@2+lg9whbU+tO5JoGJI%2^TM{csxgYbJL_t zFNBzY3&R|Q`)W#_v)_;hCp3nEL|4f=DhI4LA4PB^MP?!ctr5Ptn0*dY5^@%!8w~ z&!Vin;nkFv$dUQ%s0S%aW#vL@s=f+q5G)%+_7H_WE(Pw-|37{wB$6rlf76UTBS8(G zFkn>_vt)NAc~3rPugmV$)u6|YMc2#>W{v<)Ye2nvVN84@Bd3(|Nj|4)i@tV-}M z%aJ!7zGwwF_OhQyBabtbOFs=wWmaUzHr{i=j6D+WrQlG-pq|EIH%9mq*k%6G)P8-2 zb$0ftKYjKsoF%9!pfxPrgsAUr7X=7PtZsPAt83*ke~l+R)>efU*qUwJm6*{l9}}(w z-=t4pWH_KJ9Lc*>jGk07?wdod$~c@$fu6+m36jDJqNNR?jAe|OJMl*XhXpxEu#iB$ zqabEuP_zgY7Nku_%A&!As($d@b-um7Sr{y6=@_udB(u=AdV`3Pbn7;H;=O?6?_P>X14V zTN(iZSlxt5HJ+R}b1$f~*QgHkhQap*&g3)xyMz7}Wev;kq4nx%g^BtDhf8I?~T zm>D!vvzm28pUTsw9AY+uL3Ngdv6icmP}5F%;6SN?m>GdH%Jk@T*6@bGH^RmrPdEfz$+6ju)6I z*Ly1Rq*hu(n_J{Y_nFaum!SjHnvQ+oT2DRLpb!@ys7s}O$f)KeII@4y|2FaAZLX(b z8>`YNoP(o}HS~)ke+(kQGxjjUyW0cXLcZLX$r-gk#a(#dh!kp&9vny6_8tfTZq2iE zgB(QzLCiDs6#o<@ki<)Ys&-#n#eG$Ez`eql!a%1nfvwer%-M+KJNf<-Pp1?6_JaHX zxGA`J{&=@jn5C>-mA3ipdyk!2D=rYA`8)!Y%Mp96ZZFnu0j6bD^G6_ib@k|Kb|C2Z zBf{v>uox;FcHVq=o;Ugls{5K7w)Kc@UiFm;`rslBU(-nNS}~$L=A%iXabKPv zKf1qZY-O}AXlCfsQDjDeGEzNhLfr=&d&lqoH&cF(S~q8iGmuw4dz-*fh7;nPS1hAE zMyAc|&)Qmpl0aU&fa8eU!^X{Qoa*PzRKAmOz+i_oe#5uaJ(o|DgtsIK*s}n6k&O2*&_0=W=4ougtQ;RxFC|EkifUV-repFkn+KfWH>s!h7ZQ)se#&g}RuMv@3ypSZRFrPFRcqnmff z4gYHdh>X`D!KBoc-@r23Pip)7!Il|oB`j%w^f>Qe?s`ISGKzJgw?8=EIf*)1mr-M) z)46_x=>op9s|u3I=@%8?Rlyi@j|k&g=iRqEt2lGGg|g!`Fu_3>m$2cq2w2G?a6chX z@_W36K*jT|%Iq|tXoJbW9&akp?Gj`gl@=ZH1D17vbiJRs57unfA(%4COk>OCdjs}G z-j_-akRRHlHpB@H6M;X{(2B1vudceN-onFx)UR{7EG=hZ?R#nNxOgbkFKo9nydRcfEkK4!kQqeV3F}*!XulW^==s&Vn7+z$>|x~TtSZY)v7ujJ zQ!O(1+9MdO={VW*X|D9?IS9BoivMeS@A5q`f21d7e&NmR^>>G=@n(F8&ntbNP^uFK zyg(xjwtj50O%FVh2}~wePTyy*eNjE~ex5ZVJehtf3`#yrtYu|^2ETQFMiHAEh+X=Dc2~%GmnmO zLIYsHgqVXQh*)=;iOki`!pjWr-$gP`<9RK zes$csk`?yrBQ0c7t3{}2?izo4n{l^QGAmD=hP-l}g{<)kyQOHC^QZ4{ZO@Z(nC!SE zPW}#l1jmh}xAeR}fdl!6GTLGOHYl~OmX2Iassqae)%Txq66)XE_s@3=`f@~K;r%x# z2{|4u2^n&C+t{R06h~F*kXEM>k8mANe&8S7QodeI8W>~+1XO&=5ZGT=l_gYNujo;o z2`K~Z;|n?t)CBDM6arc<%-lBDQM!-F-?YZ}e_D-5zWi|=1Ai_&In)U;^>yKZe{2gt z)sryuOi+`Y5Rjgs9#S_k6>#j+mDOUmEKpvj5B#Qhpvp3CJV_!(TPT{H03uq+q`d*R zc9#ETGR{K>&dILUg|b8>Y#L&*&%L+~gFx{I8BmV9R`%tCth z{dP5tP*%if#1Gybd~R@U?d4go7TQvkrJD^vD{2-@J?#`YTwAk`^t=O8qwWjj1flRlIdE~|ru06DblTa7^RXwO1GdD4kR~t_++SscHmwyKnr#oK=CN&Z*+^VysWx<4Q4K z2BC_Tble_~gcPdLLP!4ft(B0RPA1tA%JY})_`O=kd?FH|D=m4W!68NP-(|OYnnY9b z2h`yf+%n|!7$7IrABnYeL#YvFvsd4E3AZj73U%qlQ z-Ir!Mj=Z3+CzY#$J_NcVY9LwHdk`O8#`q>2qIDUs|K3PjMEW&f+a&h{)CUvbs(DU4 z5n?YY`m zw0o>W42tYKQFR3lZLgm)tO*~_MuTabsR1j|bbUE~vGOI&6u-w!g~2+j_nly~OXt z8!7^e{tG>+xNTD0&GG)cSaqbxQ$l9GCh~a+va*}Gs0jnuKTT0jvG;ecSn>&)$Q%4W zKPpr|sEr7;ZN>|O2W_2)90IoXdWZqYb-q;!ru|TDs%YneT?)qm1G`#WyFjCRywg+O zCr>uiDeKw|OpT%@EB;fGbSn_#5m%h2vQM>ilgkjUJI`dk?3b_6BsXSHflFvX?3Iv-2YrhaSH{l= zZ4&R>x322qgOv3_CL!p4(4J_f5`#BX`@ulfGRJdRk*jjTmL_GsRI$aSnclZ{_kj3^ zpU~3NG1E%LYy3!F#}Yd^&efE&K@#`*8{oDJ<|y*hkIleOFYgNwUgtF(Vb53}80%_- zugQOGstn|JrV{BP%!FJhgJf%%X_+`=og%6(@~Rr{o2JUckrWVY>@KMIHnw_oA0I=VPl+X^j-}AZ`M7HR9BLJInN_7vJvpzH7QyyWI zlHL0Z*_1_96DGf!U5GVuSrX)z;}*NS9}{gAyWd*iX%x(cxgk8;4|c`#CAZru5ukMP zVI`4}Ks`;uI`9~>p{n4v&p^VbTL0fU8vBc{>u9NMl(QP4k4$&|OUcfNuS<#0gnPhl zzaFjEo?ww)4(EfMTtCL`H#x+je)*AaUY-QUUF(QtW!T?d-o1`Msde>t09PYt4-Pg$juC{zhb*@-t_zKKDhnQaPFhxvNwbu1s4BhRL(@j%N*c8iI|z zzmlFe%|>a_)S8y$sV1Ziim6Bfkl4unU*;UNwpXV@go^+-DF^Aq=+Iw9F}e2EG82!| zySW@XWYqT%!YX>HFYfp69)xTnruM3T&L4ZeaasB6|CXa|nmagM#Q3^D@@l}@vvT_3 zaK5*iX;sQ;$ky+$aj6dZ23CTQ7b5Zl&gp)m*g^6`NS>zdwy^C>6G%$Y_ z`QGa!ZxcWh)J(~32oa_5wj7OMzu%}1T_uw$Jz2g*rV^||9wXD`1PWH&KWzaoHA6uQ zs~8#%1ZWHqWe{c=4YuLUm$94QeI%|a^C4h`2|G2#uJWW2yeH$W)gKRf@8kHf<+KOz zCN$^s4i#%#t407ya#rml^Vjx8$a|822T=msvY3k0{o2VLAv zLET%RC2PVC=sYz=hm9Ge@Va_=$aXdKU9e?kHJxM?)eqCmK@33;^y zY)pH7;}6JKDs`B(N4tr{*_tG(-p z7fRvGex|-3H6IvPL{^Jz(oxT>6)N{BQ(^wK%IhA8hY;;NX&DggJjXt zT_T}bB6~)aq-o_${mSCm!oL*T&Grwh$s~g4lt~(9}x9w9={0-{nN5%`&qF4q+QHN<;?elt)_v|Mz%iw zg+|>nABs{0h+PBnc;_!0)0_SCjv5!h27|mgSBJKYaw$VbQ#anTC$So`Te6$Y=)kOf z%@9keKZet`LKc6w^GH6wgaq#xTeQ?&U*$`aKM;4^7X)U7$`U0Q1}8ElI4S0;$rtWu z5wy8@@H9A3e{0?`Lc2LlfRw>?ffRCpmKyetG;wYRmsrfJhvohbg(!(SxU63pIxft) z&KadQHwfp2^aTn8dIkZy@R0ru71A_lsGxAOSFY!I)y>d+_w%2s@CP@qE;g0Tl0cv7 z9^{+jPS$3>4{LkJvis?ke*T2to+FE#$eTX+#8K{gn+7Pp0!EJ;ExP10%{E}MD7QWV z0_8I|-sbeLZ}Q-6BNmi4cBcdPnuaE)lfDU|0iXVXm^;5YC||On#P}R1@^&euDzqU; zb(X&-Vpl}~BB5S-EqB3jl>xpvK;X9gzru)rSd(pb5CJbdj3g?T-)W-G5uGWb^N#94 zu0nNxk8YHBhQLMg4l1}?>fQ;SwP_B71*S6BDyQw;FR z+R>(w2lfUgR0MhuRJ{rDxB#Hvf^=m`(OD&6&r8{7MI%&c(1||1hdGK<9G`r$=o}rK z`6qG}AUVz3rN3uc_xlgbrD*%h{x1U_GRw@z#rz^i6-6N=>gv|yELSW!HXbHZfW zARz;~e+(4xkjG$w`qz;Pg4h!~{z#js8JswZWxEB&v{NkEuw?_5CP&SeC-l8G;f_lvz;3%M>rXkiZ(Ic~-o0fPRpBD%)ruVxqQD%xc zL-O9x_A~%SHGBb>RMiq*aTbO&q}B2qt85TkvB;c!cMxns4_Iox4>f@q0opqKh#pp? zfffYWhBheL@}!Tc!2-rnSm_7^NXPU4!ji)5bCTW-&}noO12&zefl`O^|Nf8qg!sz(vJn5vP-Cfv*K$THZILWz?^R zk6|M)9;@u7C_Uplx{o$tQ>->jjM1!0tVmHEo+~URMyuG&n%XV8>(X~79j5-FSf*$g zFbY%;Y7|81M#{Lju}ThbkG7c>PPPoKWB&-du|`_jK7*Z=VMz(OZ>$|ANulVFU@0(@ z$9|(SdFyxkJ}(`^|8-=J(&xiht+w**(bux2N`c8XSp7od{>!>z#bd${n$~CfBQFH_~ zK9O1i4{n4&{(Co#H=oV3!zU%L4sn8U@YlY$gNp`R#bSR~knGm)x(&2#I&8As*2W$xlgTYqMr|V< z^~7Tgv#|aBn;$EU4}5i}>#JK=EM9#uyTTJ0KKK5ihOo0pb5-yKtjcs0ehA6HBCEZM z^IB{Qf)rk-TI@sI@f3l2=G}=qT1$P*dxI!9knnaw&$QiY|F37W!C5Ph+4h+?+CHQ) zC!{bhm>1Y1SRk6FcArI9R1uvK*6ERV&Bs@oDgfb8I-2`;qm|88^tx%6$>I^=7>Yhn?HH%n$0#ZwT;c-d-G^26GOgrGs`dVmQJ<5+3sPq2k}jR@qnEs2w7 z4ZOIMsWU4uDI4|`+<@QtU0ums?|B5_Gq1(d=BIch!JffXifI|qGAu4=*t>8(1QJYc zh7EFE_ULuA1D=1Jc&kvcmL_Y31X$$2X3Wa>pnmuAM`bmA6?|nFGC1X9@O+JMDc=^m zK7I~N0K=uMOoY9S@D> z+F6uR`N}deEKXf#L=eim5ba(I4sjRX1n<7IePCYvVk^yQV;O1eQH^K@gmG&=rY5?* zi-2Jg{zZK+h!I(d8iG%-=kU}j*r@OoY4OD!ZTDmq{rxj5Ub{m?jxJnLVFFJv!ZEtM ztS*9ld$hy4VP)ZfFKsux%C#+?owdYcaJNuILoG3=c_1e^`T=%Wohkc5MK~-lXuBzo zpXH}4PPBMG4LEL9_+F}HkPAj#u6uG*xjE^aFr*Lw?yokh`-m!KM0sl(aZAAW8{+W? zvr%b1?2V507$pZ4Hr345B?fu@78Y5Bfi*Q%Rqkfn5-7z+L^xeheibEhf#i#f}N?)MIzIb%8ZBudzI7(M(#M;=n>fbst#=e z?mn-#*G?A>CvZ+ne}8#PA6#^r{W?FiI&~(SkP{RQWo{)kp2GGP(H17*4mL(uO~^lm zqyCJDa+Ty)vAjp#ZSfNPSn$n<@pHJ4luLei#CNB6iJMkLM|`(cJ&OAEqG3MW5}8>k zA;&toF|UEE&IHE&o!i2Q3*$Kmw7WfVT59+FhWu5gYPfA_8;e?_p=%O6TMYKt2+6mD>vGsf4pIkaxHdsD8Aq7q8&UpNov)*M_tk za4*Tp@^#;GSgScnSO8I-+`dN9$%tG%o1s~#8@n5hxO1>>DLf6No*m_7%)9Q1An>nN zxATxaTTKXO`?Sx-UgPUhWmtM=#PGU=BBR-TRo_YxTnv`9dWQad z)9DvJxjE{$Z5H?3ME}Mx$+1nb{s?u?>JxZ2s56)A>>PgFKTYK&Y-K1)coH_aL zA1V%?tdJn&N-Ou`;P|oUNmg@Sc#VXny#&WKX@MBcrDYsN@v=0$ibhD5c;LwoE0%Sr z$30djR`ae?6XQ#p>gnh;*KrnbcM7sVNl6jP{Fr|1LE`U8J2EnVCFGW~c&*)HC%tel ziqJkcQuV-Au&Y#2)uK_V@M+DcelwTy^y!{zU!$h>Pg(d_?lcY~Mhf-DfiusOaDwVK zz6=L~Hoa8v6v1T7QMd&jU8iN3thSb!OvCSnU`6`Ed;?H z1oFN^Dxip)S*XzYtT~8V$e}_Uef=<2F3p=$HA`<9(ru&>(odaO_5gdxAj0x$NRNpA zXVf3B^4_xY^MinbFe$VZ;%A|`uG>`{!{1GWs)Y-t*r=%+URlRshldDF> zFN`Sa-FE4cw6->Ng;6uy(|npM3|R0?vr!p~S=cdkSAfVBx zYXvD=GoN&is%7O5O-y zK0cpgue29V;suqCnB=@}|K<|WO2z1W{kzGS-eZkb*1e+Rk*3A8h?>_F4C}CJO>>8_ z8&dlO=g$u&4DO%TH~Z!^cXJVmAWtoALWEYR2Nu5gp%46HgLu%!09( zqoiDTLr$UAWR9lMo!bb5aglhWkYB#M=D*~aboP`S1-9@Ic*@fyKM2g-Ydu$3doHad z%i!e-05n{}1;6k2KfzESH~~j?hXg)A-ho>Vk6RciKf$+na_o9o0YPpRNr@Hjp^`B1 z6n0t!1FhUK4hAia8J9#$+6oV-V-+1|3I4f8CC^~vuzy``SY%-kLm;K)6ay7j72NCQ z`N-cK?Zq$a2*v|+I2`)vM{*rw;@V^9fEJ_G3;~)3F&q6@GzB!r(waog>pg#Kl4R$06c| zF^>!eM}GAp0^W1wEV%-&oT%02P%YCBZbDdKeBl#Y!N4FP!+e;3^a3-DZz(>n)J>T} zDFq=QV1`;?77|+Jj<2KEQ>sU`=lyA;a|H{MxX`~d6(9sIM&z4wHdwo1BYKxm!B1j^ zxNz#TRwayn_X$r~p5}pam?J8EKVfaJ1%*DG!soLvXNQOiRY>|>Wh0V9DKP(R zlz%8^$ZEC=0N7@Utgz0iTbhNhJ`vF~<#}FtfZ$FRc~%}o(m2%D6Q-get%D8@?^ zORNwc@Yg|6$X%-|IjiNn+_GV=I7{U56Ns{T9?NhoM|zpt!v3CG$Y;K1Ql2G*XJveZ zS{w9TZ<9c|9^~wBd2O?WQkvSJ>ro*mC+)i!mWyHOAgRnKfLl5+$&6REsKNf3e9Upm z-Em5hTef3EsyGNwV=BJR z`-|%r^?VQ+9hH2`adMm+Z90;d-w33YLv_7TttJO!wx?Ck9)`qS-yEQ%;DA=5fRZb+ z+;;V@{zr%|QOuaNn1LY_bPoTd8r_}4Zv&4!AG?Y`ai*%?doHg|s$4LPfyMpIr8+O) zgQ#j!Q73raJE> z?X{<|kvpDSi|9Eb$K`wUa89*pGeb-~%PfxEj)g{(T^4utPb*ZO7<*fwS7~$hkQ%wI zUzlVuVGLo5;wQoUhG5|oxn+1dx&Zu#4ejgYyvgb_yQx*Y_|LTS&w~Bj(q7Sp1nHe5)c!R$UfM`% z<%&%OAPv!+bcqAu{7nDeQcVg4q_^*vKUcqowH)y46=prZ?S1hfiN`E7r9wR=sN9%< zOwg-1D8Ty3(q3(RVbZohEZ(Dc{&;c`;iI!?;FkdH1$k?-hTPzsGOF~Grfi+KFP#=q zylINiPRNb5L6*W@9WHV7vP;d3^-8|=+<4G;q!2@sP{q_e)D646GU97^XUD^C616d% zK!ulI1*;j$Ouu(U_IeC>X_8Bbv+^Y7u*DOWut6b7rRbxWlB}*Fj2I=$%j=_+z=}-I zbQzQ{by9=fu^Ba-7iR4VV=XNvY&~fXRfF3X_HW3eX7;<;v^pLQjbCq-t|(!L3c=D3 zQRt25av}Pe2Po68ug9T@|J3*CtMiW5mA8ot!AAPub3z-Z6hn`72VGH$Go%}4=jiPn zN9&5)+~rTOL#k-}W@EP0Vp>o;@a)x!ap4)z4{*uN^ki&+Q{knZRyH8MHdI+AJ@SQJ zZNYo1yJPh_-Vu@KXi)e%-KQ{k=fOvf*86qy1(!Bxbc}fJfXz&Ud3s?t zam0H7700qB{+0w2GJeTifUCOc5tUJ;VNnWy8>$4~u;-VFa9yxptC_o^g3WpPvUDfR z7+0c`HC|>aEL5S!Q6kd8mP+4&DcTmVi)}4FJQ46FZJy5&ahQBA-*dLF?7^9Y6Zf}AH2J~)KuS!V^b^pLi zmr#A9H)M`p+;BquT5DA)wK1v&0czOdqKqVa*P1Xac%T@R!XW0Q&dxBOwhP!?Z_$4X zICZloGI=tsPw4V(8fR;l=XmIA^|1Mak)I%DRNNnHVLCe;_Q)PSF97pmH0}oW!nkR3 zPW-JHzQ>OsM$|$-78v`>y^!3Bp6P~qIO|vH*1c~S7fj$JqOj!`N)f9MWaEbGZSqg8a*sq4e1!pfTK2R|G2A& zt2emSzArik{&tFEqqv(IL<}E*?#nd#V4}(@YCFtkA|wcP-yF9{aulQ~KB4aGALt|* zNMg)6gS;2(RN?57g=>62fT(SXhG|b)AlJV3h;`>X~+10H2pWg;HB>%?w9qjT+lu&y-iIWT}|cE~A1W zFhy5LL4LI_e-e3VdE*dZ8q4F<;!i@({|=*Ev5B{(v22kTm0rf z-=0&L3)oN=K0xDK*)%=MwAQ9-2qb#+4`aum?7;oCyj=?;&Ar+5*NpR`5chqtk1YdF z%fPD7lt;gSOVr652gX|c5$W5qjotF)E9(%;=h9Pt6)VNd)xz~bz{LpW$a?+Cm>|Bo^tOLQks(CiO zOleGdliUxuP#GCh;!8Ibi8(2+GVob#+T)ZR!|vwPp%27W@4DTH1nKIz5*f4^4d4QIK%F)wQwNh)AyF8q`!D&MchvuJ(HZ5-Vv|A@lA!N0_Eoia|Rw0ZZt?UIc@yW zkHJ?HrS6fwZdI+0r~wG$|5Pw(3Sk;38aGR_Y}I=Hv@BnVVz*`?=pA~IIA1|>JAg8a zT0z$r5_sWzgc#~!Z9NK@2P3xGo!ngSj$9lMposfSmGm^Q9J|xI?ReUW{b~~S(y6E| z?_Ktsi_CDK2Dj677j(x?kCOect{avnMOk`2^EK9uK7*+)l*4ltb+@G&ryQmtWVDLO zOZlb8ix;k@kam_cn*vHXVnCF6KIO^mZt~Rb+tf;4r?!94pD$(=*7~^)l0n5HL^cu} zf~E~otmd;LL@OK-hjMG-@kJ#T7?r7I#8pw`FMlpALB(_9fnNqGbsyo|y54^}d3CnW zX-V(~%|RS05Fa66cexx*4)j`h=~_?vM6F()6TEn?>MTYqU9Agz9)*z}g?sVX15|Bw z_TJa22dCE_99OTpqh(5rXJ%ZqgH-arF^ZqUOGP^>jFPGdaj2>=Qz+UDM+1LFEc|}} z+(0A0G{USrGRC4Ka#{~9`*X5Ws~;%sGD&A$oBZ7ksn+fazMn3re!5$N03p7ChJGZ% z%5}QOS_)Wq9=ad@L|_rP1x&J7d7(5pF@2A-sX^!$P3Ewtv3<~old}eRZ@@qFd$YA1 zPajC7pCQL~fTzD8NP45#JhJbk_~shL2As(+n>&hQA>Ad@%_kIS;7AB+n2*&VkAXK01c zPdxqb(<&`BFIpi~<{IxBGzR)TocF8_+H!V)PltFcC7VVx5+o{5PP&x6-0Ax0IXL$=l{z0fdMk27JH3 zZvL&7hJOO1sguEQtjOO&P81$~UAI6OPbvZmY)$NjI9t@{I9I@>*4*J7U$$Jjf{bEW zNb;{Aq?}Y-gB-4_!nHmHA!kOI*ug8Hg&*VE^3n4cdQ>3>Yn?g1tnIyDj*NN zm0U=6hV?`Vg?e}84z7~2cqt^wy;sied^Z>OwH^^<(Ha%1G5h9@q`vw#ElRcUUn<{y zi1`gm=WDw=ZH?m2Gg49rBo1EW*W!o8cBu+?S>tBZi+7wwesw~8e z*WS6}nG`-&o}l125ZO^BzhZ2P`EN=1PTSJM&qwXOpW`Zxb|m_-Z`bS(5TAAx(4l7t zqb}+q#?n9?LaZ_j;T^Wg3;!&52_PIQptlF@S+y;$E9q<;>WmQv20L0e8K*$ z<3Hx5@p~o>I@sir9amONJ9AeaS#M+PTV0Ef1q^wU^ibwNUZN7<34(Bwks7Biyu-+@x5jcI!iR*L#>q1Bz?K&E=$`sq`K6j$q zj}DUeu*eo#pCpji@?j<^T29P%S56~bkS2rAwJ@$e|HWkwX%dF__sd?99hPXB$wBA^ zBdyt~2A{-w?VIF(mheta`HRsZE+Ze1CD{FTu-y`=QG)+ek@S{IiH!;yBAwJ%&?DnhCpPVmUfU+#G3F|NQ1a9Z4=(e3JcKodMxJ=J}Px zD3B6QYScx13^*oWxm9GhHB*BA|F+*qaAK|hTA%V(fFZ`69!}& znc@5X4`0vx&Ib1y0?1a!R_}NqVKg*0oqsUMhK1cQC>YWE5qBn>cGrQ$+tszYOYK~N z-IPoN^a#?!%r-a2_+1B$h$?KGtqRBDO{)vH-h8 zXvy2zI%>qe1>(2Mrb?Z7#kz=yNopzuK>$d%*>W?&R|Ovxb2p}hZU9u?e!=vDk{ojQ z3()ve872X98fxS%)IXxu{ygLC;D&{>>TBNt!_lVA>6czYR_Wq@ks#lKX`yS=X<{J1 z2?>PK*Tc`%?3oF#%NxJ(-SBs&CLl^>EQShwYwXvyTG*!5!x&l?i5{r0xmJ{4EyT&* z31afeO(|E)9UY6*#z3z|)}t`rgo)u;q6TU3B%2GY6_qWppdlF zZnHV3CvVHRcW6tYk5nF85&@w;Ah3CiMW;A+p2$*h`u2AlAb>Z7t}-zjSG0u1U3_lB zBB0?Ftbs0l=r1dzpqSC@h~vmK>z2Z0)r-x2b5nl2ex}W?vhcq~->|xwcPlUd6QGh| z(>&v!?al0H`r?gvrReT^+)MCH8htw@dX$_AMJDCiEeQ}p$~k2vUtghSq7F8HE`neB zTC1OZ4-JpZrY_0$DmfsESU*B5L^hz|e_01lIgves%Jpo9PhJ%yf#~VIL4KD_tsZAc zJEkfSNbn<#0`mqYG*r$l{h2A$^OPhWbr1)Fk&}`}-NMFftFOm<W=H?|k8 zn)Q(OZo^b6iT45Dj_}>!v_!iJT`7wsr_U_cg;M~O>pdN>7kq}P|D^=92ZCp|^O~!h zdIqUzu#QcNi4E@i1m+i6TgB*!rvN9xnIMnl>te+JY#sB*g*D=#A@xi2rmvwArrn-B zg$IcA07_gkm8Is>EgHlVQ3oPVCg)OJR#UaT_ink(BfDlhZM zQ#{7I?MKQL!lg%QQRBNKP~{7cIna`XaGrsy9OzEJd20j;B6hzPjWR?DYF0ukD%c{( zn7kOp)(3*q#|sZ&oIi}`zHFrK!cCz{ru;x0gy?ljXGa{cncs7>?DCkrzQqTAiYD-w z{MQ(3-pm%JsXCXwYf`A3Xt2p^WolI87)Mb~vtki?h#bB0Q(^@N=Ur&%dhZbIo+nN4 zHtclk$3Y=8MMpEm8ik9Hy~let4+1MDmhwtRr*D~^gyrl?l`$mQ#zSRlvchBcYDY2P z<7oMqTDOY*E;0C3c~s)Dv)mM|7;D;P~Rn` zjQuU)|KqAhI-^F#6eH?UhpB632d^7)E|@YqRfm=zgj@$w(ePP*)8LXc1$BJUZd!Il zx|$6TcEL2MuYolq@KUu*O#I1h#BfKROoET|xOu&d0VvfGS?Q`w4R{QST$M!WTl-9% zeVGB}4(6#nsm7tm)o?07Iv`@|(+}?iOojRpwy!a7>1)NwKBoS$x7AmXvQitEIu^DN zLFw|teb^~-$bHPz{&eaDSmhvR_${pp^(amgAn_DDIInZG)7jOvcM}=0Km7G4Deu!E zGVeq=GRnLwfjYzoW$>jtAo!~!?Y^eYRYVQkEJ5d5m}IemHQM;huZhzr8@G>s%jGLY ziIf7T4sQRMuUZ6B0KCsl9%uUQ#&>)t|+58R0t zRjQuh@gSFbdQ;6!{pV^Q`5EW=L!i0H{pQNfj|+YXG&+;j&Nj$k7J65=hBlWihWmez%#1! zp9}ocBswvqXTm(OXjO04r@zj3yd68m&qXREV%cPPBa-ZU27Q*Wosdt@8&xGlV-t-CRS9?86cc+glb0b5qCacx6n-B6u7en)G*U3BuD%FkV?@aFw|MgD$wWvk{Z6`3>L5 zTyOgK#a>~ZDVzAIhzx*O)(}4QPLHP#PlD@5$LKR*}k?Q)%wkvh&@S1Cnl5 zthg(yD4JcwGVav|6+O+!zSRa0waOaYeHR3l<>nxrthmGf?W>R#Td;G1rRpoaPN#d0 zG_gka^; zS#nX0BpJdD5N*BCt&U7RTsD?X=m=S4%0@iL2=l(6`3;H!+6=`_?zv4xD;-1eDs-Az z=f;@3t62;?2f+GADK0QCLhA{JQ^S_7b2r&};*HDDrK7d~g~7_|Fl(&Or$5ZM!`@RJcuh{dH3T@FbNDvUulS4H`e&3wyDb|(7&f&6Gh z@E7n?!g^NXVo7Ws%E&WAy~%%++xO9h0AV6@971ZjHwUT7CMX$~`}7e}*d|XPbG4_! zlu2?4EkAhtL=XS(*XpYn#b%DeQ~bUNVt)YRqzv^$cnP5-d7kiK&6i_{>B0UHzF%%g znc)3Vo!@6OocrOB!#Nwb$vQ1?4XvZ2r~loyZY%&8o^z}1-P>aKZ0ay7)TN%(dQC09 zC^FAVM`(WaqaYswjM*M!`l&~qTUQoo2ZyV1(?LvNU7ljb0gPzmaoaxSD?yRfEB#}9 z4hsU3tnH{<$w{&QdX_fvHCTgUkwC+a`{#Jzq;L*|H;?lyzet@~rm=qILd3ddibex1 zxtF84m0ydwm7kRV0wfUO^e6B7CX6h!$9s~FB~SUW{Lz6FiwAq*aq)*!%HvyGx2b1L zrEi(Whwv3I#k_ccs);Ec5{k^t?W~|)M~`D!7RYCEa`?5FjI>_caIxKr-y%cSM=tEXoOfCc>j1ME4%wf;g0#T% z8kUG2#D~->VJ9HYp*xt${*dW0$x&K-b&t^{GFGW+gi@+Xc;<+OyL6NOWA+j2a5kIl zOr%wR=(QHZci_bkNVK8!GWLlFj@t&=u9N7-EKYHJjoGLvcy6XbKf+Tw@75&W+y}#_ zp*PuG2#}o(K!{P2Ct8>5!E!eNqaJ4vrLe(y)kYAUNl&nwY0GvHMCxkkh>QugC;^xz zFBS@|yh+lkDAYZcFA+QpnA@`5HDW8K@2|OQC>U#uvSv?W8X?&J zt)6c30?Ki340-rJ|~UFupDpU{7etxc2{QhAlX&WJOMjwomRV6nAkljzZz$}BLg zxFOa%PN%yU-;o)qGS2sG1t(C$cTPGLqPcLPcYTrbh~;lwe6>(2J#MCvvlnnfJw zHBI9g#Lw3BIV^j$C*rnE%OnJE6vEQ5Hh?Aq+@gt6kNve+1}}m(sh`~E7{3Og??!M# z+*QX;Zz1W&;ASTt>f$Q1wQlsCswwUWFx}utHW1qHp=Q*3R`?KZNnd60XY8-17a{iC z0FD8qJR1%9!u&Rh5V@r(+;t{_5T^==r$@j3Ti(Hgdy)$Vs=yvY7y>% zT|*~;D$)jNz0Mz5lYjYfoePjKKS8WfeazQ|VSvw+5t6awBCRb1RZ@FJZHxoSr$ z?lOTTd0sXH6`Ei^9&r)+3@~pGkDAxOTy9j7$vxiyz1`R;f$u222@dVMaRm=Cauzh( zY$mm2V0pWLRGoC3$PQni&e0FOsEnF@Qx^n$Qot0m=xFt8tGe4*ETWu6;=a|Bj$>r1 zUcUs@f1u~3*S(=D70KexL{6)iF$f^3Hq~+@LiboS=3B1OMY_!xn(#gWFc(kqG zmVWE5vf+MiZ?+;*($ky}hQtV^iCk_Z~!yk41~TH}L|D=mQ&9j&|=GutSrb zC$h4$y|8quPE3JAg$$ThDnuNRnjfZM4BrdwqezUM6D3Jf6XW5#;G z?c~m{nj?5}nrHtZQMdM!$D=3T3-`pMcTG3c3#CBNUxvZBgrGIUf$+MeT*Vtvea-%wLlc_~kjKbv$utPU1sX@j3TY-HRUV%Ru_`)`rSZwVb}9 zeCKB9ss>lC5r9n_9iflMQo&?1%Tj&Frcrq0 zEu5L8#(d#I2Z@aUyB6D@vu?=}ZtSCI{!ggRY!@J< zA0e)rLG3w(+{#_m+XoaPm!I0V<)^TDpy15+SLE8FoC52TW#T+?uhX(4fK2Ra5il*% zfN4A1QDqFA0`q}tm%2?%N3K_MjFkiiT=(^apU_iWmbEs$jn=nq!L@Xq*f z009fHiWMD6(k&|=d{?FcwmFqF5kK84_B_hoG9baOHjqU%A7Js-6ZH6_(cf`gajt7Z zS7hT^(;yF}WJ4J(VJG&wJlqY(CPVYWM->kiUpBjbzyW z6()A~;;xohSaG#IKDK@{;Eg2P9KZ-=`Sv^;zHU;ZN>ClKvaPYE<&7EBdOOHY+Bfb$ zR`kwy96)-R1&pR?+(gM>TC0;l5J1BzaS<4Mex4E0side2Z+Ky)5kp3Tf&p)tXw`Am zAPnjSU803(ZZI2;d-Z*muB+N0$SJlG9Dcl5gpFe2$4^YRWt2Iw@=0M$XMD6}P-$vW zhiAb7U-x-IKY!)}+?dUWaEg@eR)YCO?XUlXK3_K8nhc70>^BL5AR)%qAMz`a1e~GT z{OZb%n4L`>WDm`6UIjBYO?y)sZW|V_@JARzh7iofqSv7$t!SHCWgE!SV3XNE$Y|a< z=nS3N6U}6i98g%amo~zkI8Af4#7C+9R^sx*ontft6IvXg#FcZ{6mFaXUH*5DVB!m+MU6qX(%l6@Uc_&%ol<2_|84OSf}jDGW-__Y&l0Zj#M8-J{}JV?GbZ< zbsnHv*(33VYKAbX9IR*I=gO`AO1yMzt}`f3lH@1KIar|OeBzD@;xIIh1(gDrmJ`VR(fpFeGCCb0;k@zG}4a;WsYo#42StOs*T6QV%H;i?3?JQK?54lMiC z((f8FSIj(1rF+3NxYJq`0KXZ=HZ~)0W=OgxL;&CC8YmPtjw#LH7+mhEkX!h$J7q*W zWYIOz!E_=vdu#EE=9E1P+fvaPpX3V-8gboZm7n&I?9yys2=e4I)L9{~~4Me+-km2dpCM5Lbey>b3 z68s*!QA|oQQf&VCpf)+FpNFH|f>bK`&E{4M>^ID+C0Sil!!Kobw%96Ns*}saU$#+G z{i951Xt1?UFppEs4s*er#nM`2kRTBw0BsI`Np6OW39A~BR$-TC5MxQ`^rQRwP*b9ti zV)zruOJ=_Q9~B)~`eEEfR%!FyTMY`Y!|srPARpn4?9W!}Xc76f7S+rIQsj2-&ctEU z!LaIA;`FJB_<@YMSu7N}-UqOzjH3>!OivyBfDV>=ZQ9d@O;!X2=xK9e=t@$bsG-1& zc({O>4uqeYN(8@jBKl7~3g#So=uR2w1OKP7udtOTTxq?jfxuIScx$MSqvE3C#x8-W z`OS(DNrp*KD#Z2_g~yx8`+IejgxqGD zX_WaIdWV4Hg|tXy0T2mdVih^VjM|@$bfreN+5%%i#_x!oh!XS)W*RB1`vJ-S2x4jh zuaI5+S6N1-Ouo$lpE#g=f>=H^oXSKN-(qZ#Nh^P=iR2;=T)l@Rqe~52NEjbdRvbS^ z9ZDa>!z21@m2_>%DV3E>Lj8xp*LjF7z*P?}kaFT=Q5mIL*rd-EvH>mAx+E_4CwgssChIv?FuU$>;;=k1 zdW|%yCr+y-^{EeWI(#?9@pP#{k(IaIu!ief=)~fj-!&$C9~BDX8XNns5a-A%?kHrz ztD8}YCJ=MW=pn4z==N&laB=VYI#Gml(9(UbrDJoiFFX~!y6UCzEUvVt0sd(n7{4tO z!XU1OR>I@?8@md!@ckq&89VFA%HFUEXB>ecKXtsCg7`59t-udf2DQ+Ns?wZ31c7#P z-AKpppSD$zG`Pj>?paLiNgVgv-PD6kg+Q^;c2_bdvGQt>qU*8=5&Z0UwM=tS9(+?@ z-UewyuN{ubAYHOC?Z!56zsYWb<3ZWRY22OOVJiPWI>p3%Yt0M)#nKInI>%{BeDjag zM*fH1IL2WcsMvj8st#<*ifPd9Ko4lWh*PW|W}2#7bI@JxATWD-6phKpro6jsaOk-s z>K0E#O}Cb*QXp=t!GIx5mLWBvkFP^3Ulez|I03Cht(nbaL$!RDDm{$=25))St6Dfa zzA-j+q>4lJ&sr$(zjb%j3xC_z*b zACqVMU~K`h4?BVOCmruQn=Q4YH#a1vl_O%1R8H?$nEWQK;DwZXf8SW!st79eQ#s() z=KR{L779uv0i74sCh`oEg?-4^2rv*~3cEq+M?!tj!FS9B+A)(>Pfs=1;-mt9jc0h^ z!8b0*0O7#hqYubkp@#6U&lm9~2Hizuz?)6J8WyjFc*EwiDK+mg!{luYpwnWCPfr;f z=pbj6>sksS0c3SffSDjO(FjVB`6|$_u~nI*dzuuF!KH|Aw#t?#|-J^5SB*!WU z;Ga5rO-*jp5pO{R1X*E*rKS*(vKg}i9Hod8go#;ar5M||R%h-#{Vs&32ThBEaO_3S zVLDfY(sJ3*Wp`EO67xuSRE`_FK)P7+bZrpkHH3scYPB?gXNb(_%K%}L2*zzLGaOD#Rv+|0>|tz0M)<2kwj_d!(DRG&^>R3*?V0J znA6Qx$hg_^+7-tZ2C1YBD&4aFj7fYeHjOB0vIieG=D_zZneSuwcFg4H$|Q_)lOwfj zW?Mu8s4ymlii$$Y6JF$G;r1|W1H*xJ3z<#?_a(4|KlS8tZqZo&&t;hpvCtb2e@Kkl z7l7FKHe5ehw(C9oS@+AYUx1@K_{C}VkQw!^kVs)9Ou!FJnUtcgFC5W~_J2M?L-X3l9QePucq0?dI3 zc=&gjv{zk#Tqy7-YokjQks4T=izEEBc9+QXm&dc7ef%01f%s{8GTu?!i3>URk+$Xb$zvSTRoC5EB8j1Sq|>(^von zHYWCOc~Zh>B~i6-+5J|GhkT=wz;IvOg>Do)8Rhb@bYlk3iwVi_#^{dKfzjAj0|KPL zPioz~4~kHp?;y#P@)E&~Ha!co7T){Q6i^hse`gd2w{7@a*`&R~wN@BdXCMf{vMsHk z?^YGPI{G8EAK>AWmcrn}^yNN^n8sx4G8a$zXrr%U#)5QoX5_EY!+AmiU!c~AQXC8` z=~k!Ly^%1WqbuBUVGWBQ6J^*9Zm2M8icj?jA`7A+&Gc7Z4IbXY9ogjA}$doIFuV+bFAP zcr|{AsB&+xb33VwNUCTg7`%&Uu5WZE{}!Kh{4?Ob3%SM@^E)n!(Mq6igFjQiVE-9N z$zl6vk4_p;n)^Kn&#eXnYE3;bV@vU*)jmC=KzY<`q{rfCoPbXaEmF;FfjPIJ6%;o= zP0Zk{>b(Jj`WaMW=`6J&U@_i|~He2Q}{#OMp3UAATsvz#XxB^h(fb(PJ zPz@)?X?X|CxE&&cP#AR!bmz0oQMgy&5y^ z#!+AY3!=d+*Jfu0cVPW zQETV^;!Huj1=@V%Bp6h2F6G)x>Ts8K-JTq!-XK%EY-45Yf~oihocKQg9h*y}&rcA% z#^q&t_2N47r~71A3tl9S878Bvll5hp0FzWaUg@Q@jX6T|+I}DBTzh$38;!U_$2WHt zKB3u(dw^V3x{Qq-?$tTP!u1Y`3x1b&O&gn?42vufI42`t0cFp^fid=VAo2J{aD4ci zXT&!V5Misd$>20+aUa@2egTDteS7}};X650*(_*6McEGV!VG5R;s<(Ri?PGLhsWRB zDh?y8xHn5ztga77JX{El(f~2+g)8jRUu8B#VsPZuqCHIMXR4b9Rj7IXezKOtM)<4I|87YiV zlz_nKI@^8q9zS+ymryZuT-nBxjm?-0kvS`ocGAAkv*irj`r3CeVc{nRNG?2Ba)fn5 zs6N<+^HSw|g)!pK#|y^8DA>$@{oRtDuC2?gUG;S)BV*P262Q1YDaD9CHmP-ETV4U_ zL63WaxGV0IprFuNmZFcV_$9go#j55mvq`!nuj7TMKx*iab|iWt*_E)jt8og&@VQF9 z$Qi47_nixrl06Pf3>{5Q6ZHJEo|)RrQ%?Kk%m&N|k~i4t=(1}9k`A_4^oAxK$ibrr zTW4eg(>#IGJnNy+%Vpn@ampVk+;SJw^@-sbEeVa^Ja@2s+AAafl+OCP?&R8$nG83| z5~lyntsI3nT#LX72|l7OVL(2RS2r1+p2t37d2H&H;9ZVP&po%~GRe>^Dr2Nc>DacN zmVjpS&WJ}(!@D(4xv2(Yf=4A@R}pNQZqp66ADYbd&Nh70l=%+T@aoiHA48r_SSIAa z-+HzN&(^@83T1KyHn&$$!#Rw^kl}z=*h7*)(jx@Qdb#?rj}0CoH6DBYq`CYGldoG# zpFgk5TT^GvvucDGEw&pi?1N^!M4&hQ+8ymmt6S@rrK&!_N4NO7ky(Q8(LRhl*Piz+ z>oM`wn}k`2r50pJ{aZ7(I4D4RA8z}0rC(c#h>N>ZJ+ooICGA{+<=w??jPKvQILQlcVQ65f1B+-B)tE-Ft=$+nAYCs_19+(hyDxym*=^VmDGX?EpG@ zE;&$3y}7qIf4c?xJ~kP%trPUd>b~sUVvQQ@LC7Gjaz)_X>Y%;(hM8 zDMqp@KWgL||4>_&TUzXHqm|XS+qv0smDccoBL1uMtGUc}#^snwWLroh@yppn;|~eY zE8H3sionmikQ|4pS!W59!HnG8tfG*@Uo;lQj2!CqX)>gdO^L5lsGxyv$ayVKgo9Fh z;9_<{^Z*v>wgUK3`7A#q`2ONdr4Umi-rbU~o~X?$TQRcF$;1(DAiu&VvQ~)uzE`<9 zu>Yx}Kd63$gYRUf>(4)FlC$ZJI1ZW>84LT4ZNh*739LWkm*C9f38^T~lZ8dWg;+=3 zbbpVY@GHT09b&^cDS>R^9~Q=VX=WmT8ly7hkl5V=*nRhMqSeFvr4z2f2> z^&Dc+9_N!|w&PEsTQ;9|To9p&ELBT^ z%sPM+`Ws31S1&s4@QZg5gwB5bAnFyA znz8$zv$q2e@EJIb#3I*bEiF7TLN62}ac1Qw1m?J9R_K2Jr)3@E61FIzoMlc9I;%a@ z7?N15Q32~*TgIj;o4ix0&b%Ut^wj4NTAaJGe%C!D?|TnpvzN%9=9lj1^Vo(2zR8=< zpjYJTEj}Lksv4W%ahG2d!9R(+8eEfgN=_XPt-+yaSb}l+I&wZqN5XdFoz6vcI55bs zdg)e63+%pcxVNUR`aNsn+jBU`2?k0^X-LkD%&d%GTOeo@)qSbN%r;1%N^G4qN^Qdt zrg+n>QN#P-b?zakEq@-UcpI)F{S0|>HWU{5-NPU7EHK#d7$U>8Y*qk%w60$f_Y z^*-ESLbRKgF4Qz^6h5J{*hTzO46(Rlgzi!9kngd`j+B@T+!xSX88?};b+1o!P=!rN zFETk{`Q~lLEUYyt#JhFM95ohAjSv12_pQ5aGN-TGITC)=@E`r{(n+#cGIEY@u0lO5 z4hby3Hu|jHT260*7>T>X6u1|;cK?b2rhr_Re?Bd`4c^@^_BS6@CGfA&l35WOpJrL# z{uA$!I+C{7?XC53nq@1ob#)LUIWr|p;o`vHOAIBIXQoHXt^SXvXvi7b*bFp}zfL9~9-Ko7Rx_eW4JHUAJXFErFz#X_)6K6!EqwiT75jnZ z{Tb=9!rpD|Mh5|;;jQSOHY|Uzg2upp;_+XXJPpH5WZd3Jex?67b_IRHhcGrKBEOvr zM2Gi!TQuk1SE&@~)!Zc1n&6%yzoI=;9||DEwYBfE8n*EnhSj=s(e3G1tyP+UNZZK} ziKf{^xKpT`EpZC-Xc0q@zGO&S&M7}%JnB+?S>@rWNq5bSSltTu(MjJd3+YZcu$}L5 zzE)tNoS92bCQ)fh7Sfwpv-KnOGfi8cTYAn1Ff{28t)&rCTg0+Ssr5&U@I%A;^*Ai@IWlp zPyh&N0XP&WOc)CWg2F(sU??UEg@U0#uux_c3WS7V6c|KltIWPQ?sc`^YEoTANiz4w zvTo=N=%Rg{zw=x_XQU0uJ_XBt~&eV!)-3b3_xsh!7k2=>UW%IN$ca|MS)w33fyNHCmOd$yLua}tBSpA_kss-Y>RR`{oe^Zw($Vye zr2Y5QtJ?P$;77Ul%!L0~00#6==#OBJ$HG@&U$cM1*?ZdL!Uyl?aZXjb&_%Sqw@~oD zshO)~+6-U7SGDgf1F-R`sBt6b@L(msm;aOivuLj{B?Q4x^5zg6Y5^EfUB5T~|KjK{ z8Vn7I17N_I=oShLf`K54O8kAMv(99djwMaYN<&gesDNKP|C6wtf7;#VZ*%GQaL32r z$5+!?YB)f-xteB14$fCfot~l7(Nydw~H-!!<4KUu>*dUH%yo533*^b7FL+IuLb$|^v{ zSL;c(-j=2GU`1afkS|-^Yx@hTTH%Tnnj-4qagIRaroe`QP+%-M3L(pnp}a>NFi) zdTHicd2KoV(%Q3WyDMY5y5gk_(>kkxum4_J$AIs;2v_Nd)kDubUg+qhUjKL#j{3>^ zy6w?%3kdKo*td@l0JU&koL!WDi7aI=NZ+z-ceP;@iOMPmOi9iJ-~=rhjz)%q?hu5* zHLeU901yOl0XP&WOc)CV#DQR_SSS_}1%ino7)6TL$E#~oo7X*KNLppohkkB}0zVa8 z^QwP+f~fSqH?<$o<=6K$>Kp#c`tE|n_#{>Jy*gf{AF%j9o|fpxamD;;IbN#S?asH^ z2B)YybNIsR=Z@PIxkmku%PR9N83Z3grF(SFH>1=|c`uSnLOj6=pD0=a--cwwphkoQ z(@>)r6VnkeBWk7uAwlOJ-TD8Ypx9t6Cld(5fng#@C>03|L}3)Fd~;kc&o#q(OIdYO z6&vE&Dg=Ms$j?38f2Y&cd)N1^)xSU1d{>vMe93jg^H0BV54D~B$Ir3ze$mdYD^*H@lv zUFzs!ch^{{@E>tI$=cr6@Ynuk8?1VZto?l9bzE6L7WO{d)EDKRzoajaN5O@6tA82= zz3o~)?-99wi!VhVfD_Y{2$YulF7qc_H=h>QkCtxL-eLcv3lL3MGBh$s>Xgi@1> z=Ue*UcU5$`D_fI&ZtGK7YAgZq9l@rnW#=`QNxz_Yq1As^-T30qNYN;zd7R7o#7_bN5L%yo~CPssgvTCl^=D#BSNYKtNLucb3)iXmp zfx8Z6c^&8==mLcYr}Y2pBK}|{!~Ngj`?+oNeto5Ky2 zKxwLQ2=GA0buT8O7Ft%ALoi@A79Uvku?cO zFX6wQ)4pu|{#V)d^4yOnp4MCcQ2&Ye|FP2gYQZ}j_uu){!_6cfzleN~_x#ILaww<^ zoDJ)B4^k1x|7s(VBKVab+ticok?Z~hM4O)vEhAZ7jRu{knd}pF^&!Yph}@1j=dH^@ zdGef~z6euMM z1ww&PpiCq&8H7-Mcd1-fx2vjgmo=9g(wANN))xc#U*P8c8~A*8{x7-BBYki0@K0Vd zqn~)6IqK$hsh@GbgL)(cq;x4UKS1RkCB+APV{vL{&b`ZBt zewXz&^X_k?X^v54bibkUy=~4r-$Q@a{LAA27k?|>-Q<35z3a2`4PL3!jU6VzEl*F3 zLZSbv4vJ^x<-6M}RwCRkhf0``ZCmw)Fv%4Hp4O=4uwK=a1fku75O-VwHj#M=3R)z+ zz+X({3CRr#0>ePDkSZiO1wvs^h|D5}{=4fd+d1>zy1xA98o$L&DzFE5dRwXopZuOViy+uetPgezhFupwu*JF+g3*3ym*lWO~=lWStP>Il9 z^9$txPq|gZS@g^TFrcb_{r>;RNN6w?3d{(C_3U5~hg*m<(-d&IzWiXJVjCF5Uv0imu5M%VNwwAOtpA$XMN{*D!O7+u~| z0`u_CXPCW4VLO9NOdf%2(c@VG`D?2D@Ym8=7!=TFpIA2nDg<_Ok$YnmBo!hx0W9~bgdUU)?6mxBAw%#3mE63OXiNnq6ZrPQ#q<9P$ zE|oH@2Y_|_WO9rMOb}lEa%mxrvkr;pm{{H8EjP~2pMBD8cOH3_`6_f2Kb=+PE&b9Y zd!WR5*fH~vbVzz^(pNIJ?L(k!tr&V$k7b&V#bADoaLd$kzoE18kLYM=VaOCMZI<_Z z)c}3pOF#&u(PfqP_xNfxwHJ}nvpw2x99lromS3}#?sGE;@+z>ZvpD~AYQQuUp%EVx ztDrBg=w3c7wi{>|uT{93d}3stPYH&WeXzB2{z}nz%M$X?>YjTOFsh#A8b=R3YStGg zvMO`zY(XHS@;gy9olaTG)dQQEm$g0Um`~FIH~Gx!;YdqXunr}hOt=hEdsw~Ko*YKX znChg$BvC-NJzbl)Is`?vE4;f2Dj^P$gspc|Gc(TwlDzhDlD+)| z-fnsZLDTd@`a4(H4S;isv-a-FRdX>J3WnzQ?r2$r=+lP8oYFnO*TuoeNeviGa4*V9zrK1|PW6YRi5N4O- zf!hbT8N$JK1SHK36)JeOzu{X_DEc@*zmpm?k-bkEyc|H*IiY=iCiMSCi9D0g{fwlWblpsZ0;}vF4KphVyXd~wDT7A zc}I#R(TT0JfhA))^833;xP#N84<-9#wcnP~-L&eA$2(d=Bk@pZ7HQnRG02j<6ea(HT}6WhU>S0f;0fn zQK>*m* z!1c>_9vrxRyEXF7_pt<}>~bC*RhW*}c$emwcT6Vq7AIHxRR$8Z@lvyoQcAB1d`qe+ zZ(er_eZSlQwonO%d9nakn3k~u-%wi_-*uudUE{Wa?;U;mQnO<4ng`O- zJ|{rfc_&hjM|J*w;4GIFnfU*!Q6bl6d z0brqsLX!z!j%$wm&m0v**F03ZsdajQxF3k;T$T&g{HUItG3DF=}~ zrn@`y1-H%`kT_<1EY^>VM~FBc6ji2w7N`*b!n3hhYfrE{iZC zDJT`#9)t+V4GIH7fU#gKgbN7@LNJOS9{=O|y|Vc3Qlv>LQ4y(ic^6^$4E8-Qs{fW| z%=putf39TR)%WP1yVjT8*}`;+UnZZ|g=5*zuR8mBuGOw5@y4nL!pcUv+u0R`{8@Uy zyT8x;e|OjjH3fD}JaCC1MS8xMh;6)I^W=tdAINg*XxdM}aNaub;mq-S)G3(adEJpo z9sN_&&o%|yji7z6X%rY0{ANu%miv>a$MO|g5edyz`3CsA-B6aaL4zOy0FD7V6eKhl z6A{9}Lnu%x6cU93p&=+lBoRr3LQCSftm8|rF=f@#xZP^Cy;~h%zt?{l$G=z5m%mk8 z`cV2EmmWV}{8!PpU(@IrSoQt$bI{+aH z3fI5?_x$AzhJj)rSSYdz1cIR;h?F7|2$@&cH}~H@*$owS)=I{xH>=J!bP?~i|1H+I z-*3bA^!mOZ%BO{&uGZ&;3-DdNBu#r4&w&1s)P7@t{H<@__!YkXo1E|{BJDbcKRH^g zX}owU(yi8U4+YJpJzlnjm25TB_bN|~6z}_h07KHfi1oMO*uY74 z(vk@wxxg2+BrYP$_+|ovLqUMBkW3U41p>iEkW?ZQ2#~@jEA{c!DZ%o+5Y{aU_gdoGlZ_WJk>PKImfcO)JsI`w}B zGahKjIJC2lqt6-1W0&2<7h2!rdgK0GO|?D*VH67-K}qECkXPgHbXKlNri zvG$xwez<+wYBD$68f2=cqBqsRxz?-F@Dkd$>2Chktk`-!fJxE_azl z+b&7;pPSKFu}oC6bD*L)bqTL*`>VxL3qPFHZb90jkUg=hJ)y{zGih$g_D+o@J7%LU zXZ}Cp^O$rnit~EQvc9U8v%$D7H?kW9ISwqi!i;~5n!Eu2nNiC8g$UTXqLd@+)$!$b z&3#U&_I!4aJFV_b3JmPqtx{N;<-Y+FEHiPX*Mm4E{9Pt{8RVcJLK3KN@rJA7DD@kstL*VVu3T|^?plCBFq zGaX!v>8Ai29ot?(1|K#=fbpu?_g@bN0$*((m+&%?BEt;06lyWy<1kB0)Khz1AY^tM7^bUEi|2Ok9$#CP1sZfK^KH_ zIPgWxiVyfa(FOcd$Qvy)h0+N-^T|~G+;#_hbT)*1<~%4w_HX6wnZ9;zD_j6|y~kg& zbXCA6TW?5$osN^5?UwTFR@P0!|6vWiS@FCZ3N2H$kQp1wMCO-<88QUBDKH740^aiUTd?f08^iJUQLsAdk|>{94r8?Bx4=IU=FG->V?M590S~ zqI9GKC1K38`loSA3H`7Vh>8&35JI6q?`l6FFfJdctr#gGwODy(#mHDP({gsxAz`xC zNRqR&LNM?N+I#~r!h^r!dZ%TrI=L+9Dw`ykDO z&J}M@F&2I|NB9bVF@v=ejIZrWYhW}E&wJ+rp5g^Q6+(eli`tie=n)t6bW!IdHS+s%FdaA5#M~-@P*aM~g^Fw$VAq_ls`wQjP z{)60$zro>dGo0CJ;?=+($qsbz<*|rqDQwG8djt20K;9<~>kk;k8tsZ(&bWCmBF+b9 z;_bV%ApRe1_SRd?OnYA?U|OzJ&zWJPjA41Jn=jBak^S)?uog3_ECn4Lq|k6`R0_c* z=j(?XRaTRp@*rD!HIoI>US>*3AOpB;^~C+kyWv87@bo~{bPoL8Or9Ar)CFwwDv(_9 z0-mZ3v@c_V&itz1Eg9h4s;UT@pu7fhW(5NBuUExC(-&chrDatNI*&=3tn0+HZ!$X*ZD|BEdzl2YLX!(GyHZXAC#(@bJ15!Lf zn9fY7kmDC}l~Y0sQANMx-T2i^ZX2Dm#wn$g7~5*_1V?SZQk*5CN_1f!vwi?`pO0S) z!Z&R%2TlBZm>w;=e0OfhTWFq}H-Eb5LJ|oo4v$m9@Qbv1UcWzWuWK(mw8SP|dd&P= z3k~h%MyJ$EW_)wjE@T(SCB_hQh58{s%Vy!pyzm0SOeF6?eIWYf&*0=);`h-%i%*He)esoJoru} z#*^k*k);1W5EqX0{{!M0me8vB(MuI}6FZcN$;vp2af-1Ay)qICAO7~lt40z=sWCb-Ph-FR z5)d;~_ro$tDx9ui62E*XCFO@@U)SFr$q1Nr{-2>!v52fSPWKxLKut%%aWh{&z z%Po0mBtaNlDf0J9s>q4>HZy-cC&n7 zQE}ShCO@A(7yyX-eTamISsWQ=)Qw%upUZuBrl8~=*L>+m{3aVW?ot^!=iFYa>Y4P{ zGY=N+;>r6@!;~ds=EZdY#ldKhG{3ds>8vTm@0_Hea-El_M+?$l$l_tud4InKE|z<5 z$4sbHDJ1f0fZ2USv@PfJk_P&{1pwX&&2GIG@M|_`{?>nXnA3DgEr*JSXuKeUrB8%d zG@IaLYvM@%wg)P9AC%PfJ2m!U&JB?J0`8A-WOax2KWdKamo`h@G}4QlrPxDmZbwZk z7zs-6|1S4$0{PvCeSYaO;0c2L#m&R>qos=!jc&(|9x^=NgJRu@D(S~@E^~st+3GG5 z@N-8N_s;sbm$f|{V2st2{!UP*5Bno^DIAfq{rk`tmUWIr!nVjeQC2OP5}Hh5)VknE z0@gtvLId)TY5E<79bp1oV)#VYhnzWA-99b#1D{d;TmC;kN8LoK>XlCe{n>Ve+L+ zg=ev=ib!d~fw)RfMBg@{jI(JfOL^|6funL$&nF+>VDC1!6|tXb5>S86=_ zC-L?!vEl~g$TgiX_I+J2Qk`*FT+E9n9lAKAAZx|ll{7tQ-mdq`+@CEVIiX&DSQih< zESRF@c-c5f53=(f`J>(PWM5)Z|1Yr1zTGWK+0dv4HqE=CaQ(LQ@;6MzKS>aI zn&HMpnk6Z_6bNZ2*qo4%+Vr&eIzNhiysBQAw`W5~{~}xHdeLTP>gQ8TyimLJJtBof z$#vWJ#Vs>;nIUt?tnTk_%~7}0f;EBCoqf~}mt&EkSHBjroQ7)^{%;m(MMb}{9ci3+ z=b%jF9LO;Z^WUF_kZB-HwO@;n%eFZ6@tbK^*8Nf9W4r95Y$L7C%Cvpq$wvgS^29)g ztDZh!(^}NgTku7SY1@Y9>i}>HBm}z8{@NJgICbN~tft_E)H{p9yj|Q;Jn0|w6Z5_U#*#oE9krUj=$zBSnDpAP_pW2PrbjXS}x8 zw{vn)P>aS*rJ^=_iUG?K73BoKM8J72;B75GG!cCB++`@X#(u6#lc;H*@!QY3roKGP zu}o=QRoJ<1$`hXj<)i$`^umrZDWFO7l%48+dSpRiCm+ghc6+=VC#O7hb>nHr_?Qpf z;(O)^Y*=Rz^lkW!(t+A{(N@bMVu@b{zm@f&nMeZhYJVGvmHjpuCfik`WBVn#$Azrq zq2!^e%2b=f2Mo$YI-}(ylEMpCdVA_oeHo`WdC;lS&@u{zktlZR)Z31DG!O%W_dypA zFE1W%U)zGZEha0OO-?5JubT1=;JZwMJ_i6H@N#)ekdLsVQhy^^2^!1dx7f=8K^$HDlAQrWP^p^S6)& z-Ws2OhAN0<*XoWsbl=7ZItA-od^}|SkE8=lk^M0#sFIY8WBKqqlg@s}Pw{e|dkJn~ z>8OeG?5cul?pVMEn07t+RJ9;b8YB`#`WBF>%3`lCUhsAZoKybmezS;wTG_LHnbgJi zLUxU-FvagkmIGC-n8Y*@VJ-(FeH+ZvJWiW-9WGk}>(y9sDj1mxO(lh#lVM0`Qe6ah zj_kS^j5I67I!h1%&6=h4hezj6dL1d-^!j-$ViOBghuK4XyD{@5==((|PFOZZwWsi! zdNuC)ne$V)=tYR4SaZPpnPhCyNT1XqUKm3)K0l6 zcH_*hiH&>Jc{pVU&UzF*K2GvV0Zm?;V`&im7mcKE>Pv$$zs^_BQ0IFjpTgf?(OPY# zS6Z}F_?vArkr7BN)fCeEqv1=a1cLF>nX-TIjwzt;k}VKu9!vquJ4hBS;n zp1pth141bWp=(BDzZ*JKTPsiJ$9xhgE_8D?Dyrwe{S1S(Hei|R&>F1J!Cd?8VWn!s z4vWPRK}1eW8?VPS^(rz@cl`M0NLPP^h+`=sv;)5szNiIDXU^H5paittwvRy}cLw?6 zaJg&{_*sP4k&n)DS>TX;hIW>UB8u})W1D4|%>E_hZnTOl-)4F>;g8g=UW_^Uv2VNX z4@qIXd-IYk@%I(VVg=qQj6%twM@A;mrQyQQ6vZA{m8Bn?EM5iTQ>k8om;XAd{yz8e z9L5VhIIkQ2tKfU%b*W7J1dt!q%2W_m)r=JA=3vmAwkJklDDCpU?4d~VLTD%R=qO3} zPRhozRz9FXr7nfwCYerdw5WwaeeET!KuBA-7wi};_c^76P@99ejIry&knNoQm2-TF z4W-7ME^p%Wk2@X16Px8=I}MFQhUhow6B+U>-fs<|%ZX$re~?2JxBvpLWv zey$wI2Kx9{+qUy#*!wSQJ6EBjl}*-{EBx%!!=>)92LmqQMs4X`B*Ph zg&LP~{2ftO`)=IzGY*z2K6XBF=YyE+C%91r+cUG>1dAKJ=bF6!khE~0H9AIQ-U;n` ze1~|v?pkqz(>C>T?cRc*t!}|((6j~BeB(Oew_RybJtA%@FpMBH?;?{r#ETXI4ME6r z68uL=3@*%4q9cxjK%rS@#O>Ti+u|WCX zUtLE)g2UqXyi0-A4~cax@$7yPqdGYFo_x-D-*e;NF04pW_3c`OI@w3(s1-%-d=YR2P{3Qm3|C*%o7SDT{>U`<$++6XoKvDg=1q~_eP}R zHxE#h>s0tBI;t$yM>mBA$AX8Oq>;)kV-sN8ZB~)PkDV6V!@|~O5UBE4_|Ngbh>}th zyb-ucrVr#C^nWf}gHF&S`@12Q`<*p)@OMvnp zSz1tw4G$YmS_-%Ec{31nbv1og=|1qqCu={W&6f2?!3k&J&zVUt0O?%k>d}ptC}|M5 zjU(!P&2WbB{HcND$BxJLe9P0lH-UtJzTlUA-b>8^x5R}D!9CAF#-(2U+YdW$sO6pD zp7@|acqTVtoQSIqI9!-kMw8=t8(P5(3PJPBbtDdY7;5=9j&*w;brlP7zqw6Psro=_| zRZH*dPY!{Shk`1!kKCWdk<&wE_3M>%FSn9e zQ|ING`VVCyB%D>2sMid+^gIe_YeGX~=WA9{^SEKnweLC= z{9#`D6>+_e#rNg%P^5lD6)sDUS$m|DPy9>0z^ZzCyun3}F?;uh=7-pEiZq&NZ_h9e z?!=4m7NIigA(R$yGN!Axxpe&ZlLSLRmgekbQ}m1`=n)+{4gF&nku;N@Vf%*d?{-Nw zruylYGLt(Fm@R897*2$%P)@kl&WENPbkN@|?~WRs5}fvx3)QhJ%Qs#W;WjeE9I@rn zn!@u_AbsHjBbGxT!9>|cjMEI38yM3M`O5?&71MF$s=5mpqs=OKJ;Hj3}zEJCU z>#vh+&Z3ftZk4T-R&)Jn%G}2eL?+O2aphhk5+rRx_=7&6xF?g~+#HuT!|>~yF?)3j zA&pI2a@<>oYBc(790&d4XSwoa=hy5HmIL@x|1Pwf8Kiu%e(k*9&gcC+RG#WAC*7XVuehj7u%WXOWCa)PWcGFMd{mRiztUBVDJSnKj z6&(-*QQChV7*X8o2O$KmbM^E++M)y#eD9?SB|!W~%X(&Ue`p0IpliVRo?1EWC*;t$ zRlFF5o;Z!V@7F=tP-fQcH(+)>z(p;G>LOOwJ9L84_^+wd=upqqK6(t)f}+LCx%_7) z!|u#FB&@=*_xJ{knn);hldKeYiX?xhCMIgyoV8|4EEzya9@?iU*0MCq=v{MCF*gDw zlhe~-HEhhL%wH+ln<#S>GY+%qPgLy4H8-2|FmE8We-qFT6%rxZC(+`-dbG~t!Bb=} zs*{J*Q!#Dx(rFokq2QcoH*Z|xKn~|(4a8C|`D738I-?dH5NSzT4fCApoqD^Yu*>>U z;OxFSl;cz!tbW&sw=cYOm{&xA+D&%`dYiFcPYtuAHB?KK^d(c!7;2?`%01p;!cZrp zFytmSTbb)I&dP8sqr@<9%k^;{X|>LC|7l@aO6ipmThDnAQ|5Sp4jiGp1k6)7g#P$u z2UoqVRRY}3hj~17DLgiAVbQ-qS6o~xSAS#m_QlW=BJ&G`N&Tx|@oARVm;NY4_tnm^ zdFR1!^WyuSZae{HA?(%8_XOVeCw4hLCCHF6!7Hi>aSkoYUH6L`kgJ6(XLzlY&|W3k zVB`}%Ry*aoTeA6Ln19t3>F|qVF=LXTs<=M1hdsu8mO9(FG)ztM)pF?{>&q0Far^Gm zVSn|nS$uXz=N=ZI1W1C!jsQy4o4<&iKKQyiL>lL^|LQngeB;hk@#HVHx7Ba-RF;Yu zYdlXU!82QdOS4@dwjelf2D}+88oJPuXUMD;vW>1!)dUOSSS2Rra6<`l5i7=RDZ7&B zAOACz?t4-JOnkKyxE)TWzhBD+un9N2EA3S2GPd&4Ot+O-mDSCCP*wY34HG%z;@phy z>5n94q*sV0tfB{1OJ!xATS{ncZ$={S6F+{FQ|Rs!(9`sYv&<{XHb(tU9P5Iy0#gHu znihIn4}o&~Ge5|&;aMnh&{sP?#Sg_SPh&l&K<3YL*J&)&W9%~#eJHGi*SU8vUOV|R zNymyzaK9GHqP<@&)wW^1E`3w@8P!$b(=t1v)X_-y|7eL8(u2+nl42XdNv2()+ER{Hy& zNAokDzsJQ-UtaO!=06Gzo9k+UPr9J6oYTGx1k$q|k$b2<{Nu|{FJv}gOvABl9}oR< z?@%vDnqGiY@=tiiYk@$SoR1OH1!fMM{71qGIo(H&`xk3lws&c4xX_vb5n;jC%p;c4B>nw~s;%`r<;TzA6v z?MAfC_kL9hwsJImL>IH#J;33X+Y)69{@#$QnIc`d2ho5#is8HELYSb-qyV7vPNLq% zR9-M%IjwJnfV!b0;xuDJNg;30RQ z_3A22D98i-uFNVBa{&Z##u>fzdqLFSwYSHkdcT+WxW`$agT}yU$MlZ!ZP82s-L`xr zXcg3UDuH>ry?7e5QOvuXcr$S#Va)?R9|9-coxrcf1pYxJA@DP;rJR?@yA7!MBC!6W z9e!1I+3x(!#Kw4WXNu3?fANS%^vHAOqjy><9(MWs?G;d6kxL#Bm~#l2SfiwTTBFn)N`KE-4C_j-&?Dc zpVnf#!VrN0w1(j%Yfp8@&tInc!qEzdS7(Q~+`9qg=U00J8LPhnx5aC8Xu<{Z0iZXi zzNE`?m>*|con+==k?gf&eNvxGo;7C{ zA3*S{Rz_J0o#;mK%|* z0Zas-D}XqTB;4B1|#Sbp?V>G}q>%Z+@tQh#A!|%hIL4`&WTVU89>il9F^kUHt0G46HX$RF~VIx7pUb*%$8s z%ezg1e5d@V&8PC>_E%vZ^?_Q!x3V?kHg~mi^ew~Fi$Av8-`#Tu9UtMR7JkFcv{5fG z3OOi8lZ?Np+1<8R|EuXgTUI|*FM%JuTrUOKkX88@F2ZwM+xH!$gb{Y*aYUUg9E9vw zomgq~e$fo`Tq~8DQQcp&Fke0))X1p37H&pP+~Uz_&K4$Yp4W%xnIX64j0=WLbU+5a zU`B!)k{s};A*ecwzsB$W=h)1&MmJiWDR=5j9FMP+;yO*`%RAL~yT#-Hj^WOF;2LNi z;Aivfj{1je1ylU69BKgov?S0JV2&J@_uICp(y)2oR{ZFJkjKB?1u7N%vr%78|BEEH z&WH!IQipo{H0${aQ+PqzvMAv_d>=3izuLYAP!>N+e70~XET(_bB@fCL8TFiS^9m6| zO|ywiJA*eb!$+$&=NZT;C}L6gpY0f$6OW0X1Pyr*##~ZQoe&j%5c*r6I;1;|88DN~ z(qiDENvK-Ce|)@}S+8CW3h)$KBC|A}T~f87{s6DKG`{IV#-0p+mt9 zSe%3?2+|=CG`t)k#Liv>0hUYid8DpCo=^F{?COs^=Zju=cXnhb`t||B&Lk$+8;uD- zg5n%uOPi_-`}{HMrf}Pqh0EliqW3l@T=~>Dz|gYi#J7P5zFzyl zac@I^rl#{yOl_g#M_Q4`>bh$viD-q zPTEw}w!~W~t(k!U?F3@Y2sEud!=4K+wqJ6uR9pCt-bKkbkgPenUKfra>i<=|Ll}qdr(C?V9+m(I=nFZ5MfN8%f z0Ode$P@skvKE6|usfqqW*mF?94eq94y>IZkrMXA~KE=$fKcP+xu;%L%p7#rzva?3x z9WN$qL3@ASE;UgBxU;P(CgwZq%*sV+o-hSN_NyADJt`3}|D&Lv6UADrfFq%=h4jh| zhkKOLl!b#@S7b)_SDwNb(F!HR^^pk6^SeTh!SsFC)OcO`<}+Gb9eJA&B6a`7r?e;mVT;w1(N_Dx9?e# zMegXNU^9M~aGW3BPZO6-;59sK324*AQsO7e%rHpv)&Dc<;qcX6l>4{LOljpFl2_Ql zbz)N-2~(Icq+D*^o|?sJ0VRpu2hEO02iX(gBcvgF!WW|dzA?#mb#6kG_@NN4Xf0_z z@%QD?hr62||ND()QB%L)3|gJZbQzRSLKe^dZ4#Nq6sO;Nt~OQAZ>~p}?i4()GSHXq z<}r^CH`TvPP8-@B|Dbz|4(gCopOs0u zGq*fHfIL#%gkg=(antj4cC!@Gq;B#@AZI8(S;BS<6pWMC!fwhiK^apO=KzBh~9L87sdG@^mj3z9E*v=ceFrfPoKesKbcBBPa{cfUs)?m~6m>MlpanMple27(`A>miO6CR7R-hye>Se1wdp zVusbmA)KA>&8oc_pj{Q9vZT<#z+TAH3vN2SlsU0_08Ix?zU(Kaqto{WE}T93@m#2- z2f-J$=1+K7eI!0j7PZeNq?Up7Tdpj7;eS`=2?P5+7!5&J$Ks#v+=y!$V>e+;G3W;T zf7uzkA6<<1>|kDm`5SLAd%}Eb0o~;vLG6W>q?lvxsK(8E2lM zvE?Tucd2y$XA)!9u?@;Ml}wi&07@t~tW+rHH#R{O_*m%A5Je7K0lsT@rcK7&cn|sM|l!&3z2`aw*@>oFfGXB*)_4xLz zy4<(RDp`Pr^NjcETyk6f>X|Ye-n*Fj)Fk$Wd~qcI`pHy>67W7mE_LNrB>@l;jQJ(J zho*kHY+ndD-%}iG&|^u7T%tON{5JA+2*SPM%ScxW%s3gViR%`3aOV&H(VgIRRC>?zMfYFA&+7JnS6Gg%oIa7vK znRk3uQ}eQFMv;jctWkRza?!?DMLD(AM2qoG-hMTgu(m&CUypr{ChDhZs1rE>xYb=E zrc3TlyZUyer7gTnU$4;jHp7bRj8~SF*Ew_J5lLeGn7jP)H#AkrmU0aJOyzU9Dno!9 ze?-*_h|fu>_>wCUge}hG;N545x$K9|Roj_5$&iiXiASap!`Q9kmmLr!~?-~ln=+ec- z@hn~}8@Vsq_wsV9;*)-?md71F=VPMa3Y#xxnSenr4F6+xlJigh4>m`lDiO!=XvEPu z_kpx5f<@Za{{1j1UlQk>glCk%U)jlKL5#ZcH4B_q_zYN&isLv{bnShDxsQ`>L!3?@ zuH$M6shqI~SSX4ge!@p9cvMu^O!{N*YA#+@<$Wysyr772{L|%KJK1WMf5Ac#lA?xv z0>^)wMXvhNo=-j=IwxeDQ;Nc@XQM1Q`r~(c_KSmeqCavJnHgr0!Vo2e@*?3Xsy&w4 z-;?JCp+3BDt~BeFe1B$O6q#&<nM|OU#{a)^4lzwIzfX_Q2xG)B;luSqz(HVqS z>ACl6N8~^C%sIXK{;Z=hBk-y|RLK?6ZNfgPTaS!_(Erkcck@bDfD-AMqa!KVomf{9 zG*)=sl3E)xCR|3F?)$tfU3Q);acf(TPmi$?S%;57p&i2z+N#$%j(3qj;t!Wn+rRfE z{~OW18H?f)9RKxLevROrp6XrRHg>kb83*E~hHxD5L$929JL9H1ukGzY(|tZ}y)awk z&S%ds!rA-yD%I6bSTmSi+aPT3bJ(VJB2`0o(I3N_vVK*Be6v+#m}6*_!K$SfA2|IBQRw?{ZOmoGwxey zL78ZV@77jk5O)ybPT0P>@QQAck#U#Aw7~O4X0O@kPN2foj4DNZGdyDZ)}r@K7RXog z_b0JfrH0!#iw=Dt zp$=rOkANnlegT^C7Q{^6WQqH0Q$pQm*cy%BAg&mkP%DX>)SqJcJ5kI6E_n=$wTyy? zVKp>SDlKnt&6PMF%UAWSaH!V4Li$=^>7=3FoseLp+NKV2ZNB+l<weOOoX9k?0%@x}j)GOL_p`cg9)e`m)rN+AKy@xk!RkQ_L~ecs3Vq87qSAf`+lBft;}@3B;ji# zxs6u6c>NyiN=;UtX}f)#Vk>GIJ=~6l7BF}YG)_XndN*S1mq!Rl4o<7%aYadMbzH)| zwXBcn06n)>tiuprT_IAY%S0Ai_4cWb+5FdRBMi9|u6!ZDjer?Yuh+7|mV|e$0eu7s zJx;y(Y&3nSUg} zp!HX^+ulZAy|YN z$MjMolYOwSF?1Dk}On9G_4qVUqg7(MSu*^lHW<*FF5>(9%;;m|c|O0)W} zEMg~-pdo}o7xrai5zzc+5`%c%xFLT3@n!b51r(m~>ZaeEF_Hz7)w6XvWnS z)21_UrpAGxU+`m}X~(@7F2&{1x7K?Lxe^8wv_%Zxuu zRE@eEiLspiGTZo$Ii`xI#BP}hnGd?n(KN4W^l`8vwWqd-GYwmz1tUS62mkE_kR>t< z{fiS)LE}b*?n1OSCWs_5(664QB_~B4-nI>_y8xR`yKeu?@9oH(A@;qPHmb2|bT#P{ z`mTMwJ>9!-i@WJ0abz~tZb0mzE1>dvQsnwkicI-l0y5`uiUeCJe(&%#v>QykZThMX zl}(xPAm|;qRjUha13tfPmhh@)qVA0E5&Q)wv!M%7FD8EIT}p?oE=?}YSCXx49L}{>09lX|?s*+0H1-om&OIeXXJaB$2~br8^-5H^ ziJ?Oxd1XV*Q8s63qei5@kVrE0Ue58A1ceGhU8SG}G=~Ti{j(uGOH4dHc+{#xolfuj zW{Mdp#SruA>3nQ~NoN^q4L$MOqa_B&|J=YV8h0ng6o+XVtc`bfre8KGMXv=>VQ!T-ZEvmKCYFkYKgN7Zz2xNZF@LilJ?TB;#nC&8LY&EOqr)#Xwm?}u8 zMB39|J!0&jDJQ5}7p{%;+MxOR4{(u6C(rHdh-;lXtIvD! z(!DwiHc}<8I^Il_Wg*cEsqZdVr{!H%jkjK#nTjG!mT6u*D?(UJ?WbHtc>1@a5BrNc zo;j;%4~AXB9b`9FZQKQPh{Zm08`q|rt#vkt{m3qXbtJKd9J!L}y2tW953ch+t>-5v z!XH8qQV4c0lgdBSVAhWS?jE{?$L%J1Did-QwGvX|&aoS)16gpb54?@^GpMSOvEqwG z!evolrIPwqvHmlGhJGtdW1>Ndk*0_0{?Y*v&5ftriANf_=e6yOS`P|2imo;VB6h5Q z2f@2{-Cg*jNI3R1e%yPS7XMj(z$8KIEx29Y2AAalfkdB%8G;w&YDkenAysG;SxD5hKzbywUUOk-G`0Qr-EU}lgtnd`6+}S;@ zu8S9AJ~v3%Pjv&_%JM29D-Wa0~O)HjRF z7u2mm^$x+^ggmQ+2|+QXBwY2RTZl{tR$ZpgqCcALou6GtGaNPXQodbgA5T3dT9a2d zU=@hAV=4F6>b1bWdfx4#hMGPaNCkl_`h7;ySxoehGtS+$u;Cja$2|Sf8)R|oKt|aI z!XR3p0s&40+ApajbvkO1i$Gn%yNmg5Pb0tQhvB0t!sxGrMhGTV+r(YB!)Fg~)rXT9 zbu|)mz}HjfC3{SG!Xb^tp4QF9;~P_s@lRA0zvRtxAHd7YY+!B2z|v=qf6E87_eQVw zy;N&Jo)ud7#i56C9>X8^*ZC@qQ9GpAUtmV;1O_yf8|E5p{q|S+ zT+3xvC%IRST->Eddw5gxU8wgwfpdD@Z+Z`cCmRQP3^+v(y3`x7Y5jK`szVMDZDcrU zGra1v(m-$TZCuvrW3mTr9R_x7Ll6w*@dHO`x7xGEdXV6&1W}Kv?@Qjiw)nXl?u#p9 zZa4E`aMVXR(Lc-{@6%bG4~?M9cX~{gvrSRKdRnuU)5MGSx75lE5oY;AAp;Ny+qA|p zYGKsMS%bT@$;0+Z)`?}Us71I7(%B%y=w(Da;~Zu*ju=1c9mUFZt7~-uLB{e zl6Bud9sm~w%WdM_qF_LRr&D}>@Qc4&updf+#B>mF%}D~VKl*1-475c+Q2x2>_USD$ zhNiRZ^TXubtI)j#KWKs|@TZQf)Xt*JqidU!EP{zMlE{>$-h7i(|2qkA_` zVQSq?%~Q%&aZ-W89|EeLt25Gwzen7ukHKnXWJbK_WU_Va0>7-8=QKx&lE)B(_ z2@I6A^>v+2!$l**AfmDjCkZMIL?eT|34Ro`0Vzm~P9b4Av8kz39ai>HQ49;8PKY@$ z|CoF|nlgMsLmWN0!T*nk zName5`Kk{b@M5%-PFuA{t7K5v6kk@2oj$5(<3Z*4pvDY>eX{*%&HD=DzOJ!T+p*eg z#`yF68|3%AWqZ2S%@dQX7A!jjzgA2H?}ga+Ee$9{A!+M4gOev+NQYE8J=mB#iHbfM=t#4QPuK=^Wzf)@1sAz z#P_L}3ZMvL$H!Y&8=Kj101q^~u>41Stnda$mOnmdHFtmR)~---*WKg1>3=}*abUHV zXOYVdhwZnH(oEu$`=Ci<+P@47?JGUOD6fjkHyyI(3Ns{h{nNkMMzpi;*CWT4-6P3K z%gGf*tKxfk+7V$;hA4<^Y3|0?6JZ42+^tdHD(8E0tl0>>n?Fcyn`1wD6{A zi=K0rHJ6AB@M9Ax`fu)R8()6ncXrrh*&`{GkuAuCn*X{#a9J5i?5T~r;7XgoWETq>dEkm9*j35@1STLxO>pXN`m{+kHXO{5rb-jQGix351i78hELlGeqmMB(u?mj823 zF;nOp^X2NLMs@miDA|OG|70;AoY%VzQ(aJOIFrk;{2_+cA8T8;b#zSpaU2H}28;um zcImBkK8C?3-p}Hi@*}J66Rh$yb(DnaY3}mpyhRE~3t5!DT6el_%fwy;cx;Knm-Y)f z9KyL*Pa6G1XKz>qaLlzswcG}M3UOV!FYSwrYe?J#VU6VcJ6;der~k5fT(O$YK~2gs zZMMZ)2eJL~oB(OBDl!B5HQ4YF)XG_UP6E)VpPQTB@9eKvF+1m54R364RhjnU{k)m!_+ZMfit%>xa^66v=x=CJE$Br`0l|mI z#tHvy@|Fb5XroRP{=|fw#mi+JjD2ikS?q>1#i=|>3pO&4r|4HTT@QUfA5-hcqdbo? z->j`@Lt!VRcja*9{p{!JJ;tjh;MUS5py%0^N|Ru9Q5~U-oT0LDCiP`nWS&!ygNiCM zsyd($=olm6J^#WMb8EBQKdm^{?ew>@Q4oJKY#@z@@+4j=0|wsV)6LK;i!h7Iyyg5S zk2Z;iY}9`N5I=5lxX6G3rIYD_otU8Ek0dU&NwYE8aV|GAD0t%vSrKKfp1kp3Xtw|L zMaU6S5MQhl9%>{cTCH834G$#-hK@7FAbS-5?!kW<5ajdncs1qxV;r4jk|Cr=6e5!C z&urRON-R9y-PyHX_7bfYj}%ngV++7b4w;@^=QLq(gC%nN%d_VsnErn}or8DW-`llg z+je6&Xl&ayn>1?7Nz$;fZQHh;G`8)=&b-s_?^*Ak@L4nKocrv1?`ub%$NX)yPp#{1 z{vy(*_BI@ENnvj_5$#4^k3$vCn%F zUYAF^5)GX0t24%$$-pgN;bkzmwtEKW9lXWWZrO&pyX&H9S8$kK!DU^631XfEoU3R~?8F{@lZ@&(41xH<;J-1%|a~)c}{@Ilu9J}*f5w?9L53-7hz7U#FAWet`rSpx~l`ZAN8 zWKTgy6>oMm=dbS9+gTS4VXDzGJ1{O%4eB3z z)lJB6lg)2|a1+sT$3MR;!c@T6RP}_n;QBwSIf%M1Z<>25wj;5hkg|Uqu-jWEXLoRH zsS4rqbJZibF-xet)|Dt3a@!r3BQ!7mF5ckx#10)Q@`7QHMb z^F^0G$?CkO>(Q$wZp0J7ny&7T>(;Ly9~JhB@$|XvD7FT+!vS4$&xw)_JAn@*et0uF zK7k*6z8s(u04_J$^#!UyGle87s07l1He_7UMiTucggpzY)AYyR$W ztEhJAe?!$jwGo7U#1<976113nyt!$wP~)A_knzyWhbIR2Sx#oGB;~6*Lfi$C09xFP zS}xW<05h7JHo)%N$cBdsQ;3}P@uK55ci_xcA_Bt*OVAgr`gWn(&lw&fP3u`9meB9& z@Wn4m=k0e7EK28(AKiD`U*Y4mZj@*2h!L&m8S=Cy$w1tslPn%eLCx$FUQl{RF8P6b z*(35b>Om!uaWr4|RS~cM{mrQ2F8ij&>>VeF35t&27u7GWFS`Ujl{wmE2Jio#Q;h1g zj?_pX+A%2I@ITe$A20z6A4+|x?RDG8zPk81{W|LJwW`KqQH72xSv??lsRXj))!Xos z?0N2mo8s-okII1i9WcTxv33Oaa=d`KjvZs+3jo-*%>F{`*9D3fH+-nWs$ZLBcDEfl z?yVVcQ$E_<+c_A%(nUIrbp*3jlWGw_kzL}8+RiZ$yC|P?p|t&Ss0y)!2;tSGm3xc~ z-&f7&NgxqzluXKmMoYZv!YGurUIOnD-@0Y=*e)(`y{ixZ1S`0oh-`F5j>-abfZVB#0)*vrO)Uf+MdcP#4?n~0FrzQ!oNqJO(jFBt4w}%=0i9=;B{A?%eq5OHuAeL&_6hzE zyO8hIsB?V|gS6?uJMW=9ixz=#4!zi^kSZ1Kfj{Ft`gDE>H^05iS@9p0+`i>8oPQ6F zPv}0hMVuPb7cZ#l8Arm0`^Q^GOZ%LpX4T2ZuoywNli{E@l{d0?q!qV_qn*gLTieB0 z2H!B4{p`8^CFXaYGO@>{bef#;=<+tRig~1O8*9q<5MV3w@$S>M?3(}NZ7HNA6#3-w z_{9lET*f#cBpbP%E+=QUJG(0JhKJk3*Q(@%Jl#1(boSe}{fo_}UkA_D^+k8!DPx&J zu)O|30h?^)d>^be6jcefjI6P->0D8#YE&!Xh$#_LjGnV`7~gtOaliDFkT2|!gN;=O4PiSzfnE32o_(`y)orblx|zq&<1ELVwjwNyu)b9KFytpy+b8fzb^Z3V8CF zokOZ4aZJIX%Q;;Q$eU~|?XWXtDx#qm!Ch5}{9Yf~IlTQTuM8qe%yI7Ww#uc^#vmO|}gX4_s?zA1?1aKdf05dOX5w2;U_gXPS#%x#<3-3HzV_PHq9$Yexd z#MUTqsI(}?=$h68mZjqKaAY$b01_t1K%#hge5*k$|b5hZvwsc;4&`R4NxyIBm ziJjOm0jUFlME6K>ZDC8$bwT$XZh?NwFSgiWQEl&+J)^?=JDYgpb+V{i%wqns;cv^o z1DHJPXhR;78~2Oz%`EK-&sm&*2(Q9z2`;w6=#7N)UZHQax6MM^gtt7owuYsA{lip5 z+-3dk$!{SLG@BNo?;m?Ff8|UVh)a8Sym5HGeL5O`yml)S9GR=2vKchk$gAt(-g1PwLfdw|eL~>cH z2ROO@l>XJTP}SSoT|lCJ;Iw3i^pM;pig2-0-;GMK%-*>>{1`XL!W3dHI@tF(KK zX+SE8RCOpPf3edsu-Y3w6>7tYd6+EQY=Yt(%V7R1i%6@E95J_HtmRfHxNV&nU)wVZgUtFSJ~oZLY{x6;tTYA^Z4i56P4*VG9RqG&6| ztGXNqTfbU8YYuayRIVNqg+r1?_Mk5g_8Gkw3=F1RBz^dQ^3k`xs=r109_VUynRmOq z2j$q|gRj)4W@v?03HoxEy%YU4J~@NQM}2~w4<@x@6Jc-i(LG@E)F?S6l~}I)!0*F`oo%G{eHX%`&Uk(%Ft9>CG1SKJmVI~EHfW7zDe;Y1LwSS6;)!6W1W zD$RTFhh#HaG8w9035JI_7NS4h9Arj9_?i zRnc5o8+DKMe%4&_LjyYv6yGNF@?YC_M}=PTWDvpKJDzuoCOUDA%6rL_kGa3wmva$o zd2Kar@Awf$EplcqOqG6BdX2vr@$nzDH&Hj! zh#I`07Hcz*CyhC}oMl=w4@LLWDEGEKDaud=cx9AmVuQ0lmi_4(>Y z5K@T>Qy9_BLTCxk9My5#l+T61opJbmtQPRRhaR9VS7pY0cL<{0de(5;m4Hi@ zu3(r(!aKMNuJ&`vrcE@uNCFONtACL0^0A`B@qtn}#4?IsnDN(WVgzQEhI)2rG3=?m zn)U4kUH zJoeZr<%dNmjLSPV7hd-KZ2J!MpGr+ENHHLsKPv6$n!`e;jEZ`}PXo>xKE||1votK>)s+@)u6qPCI`JR4uQ%_h&Cq3j$}b`wOeU&vS#t2ZmQi4~aZXmsjkk)?KYN3zFPusS~+>>=TFAPS?c z&l*Yd2dG3$WEM8u;{y}9VGjAW@>n_ zElZ&oxX)@oJV&@|o`+6A-7=ev%5#QE#LOR&xZ7&8j7I9MuuEXZb*E2}CUT3@ zH=2_*LLobvj=ifsDZ-6`1SZfEjo?>ym zi}<$GrsLCuyS&CxE?P@kC!dmcKc>vpcOJHkFNvB>=7~ZW3E;e;_Z|k0*O!)~)o0sB z0b@LETrU%ZvjacTnnQPzhhn35AX4T=Qd0+qXjl*2-7U0Kx!#XQTV2k9r{xiC4d`B3 z`Ce-5`nd{n6kq76@vI0}2Q$OLlKtinXBpCe%-h-E9{)ZGLn53gOcU4fo?tFYy#g45NqKFMBdam??d z^75cG2KRHu*ZFa*PNRr0^5@nUOmPOfaV~lXC0xe25?^c-J?aFUe|rNiirg-&J*7Op zA7wT%Ys4z=$iQkM0y4!cepM#zLaKwVPIG_LX$CktcqMV%xO(yFE9Iq*gOSAZl+6p? z7a+M^>)GacZ1rT+o#x}RUUHP@(E>()r}&uf{p489>OshWV{#DXxoZ(m!$1R zAknt~UHCME9`5K{t#BPlrQ52;X>LW`mjLjxU#+SHD#XRB}Mecfc$lmN+_h&5cD~FwW){RepRq)TX8xHaBQokj9BLmU$CQ<- z%qe}o#Gd(B2(g469Iz3I%9zV5h>=y_QD?n4^ zZLnv=m<-+w9?aqV6Kq388rwm!nWuSbW(_w^@o^Xo+hzhJYj(WsQ6~LJ={Kj4z0?k~5jj?FM z-lO;yZoJ9GZ*Z_M#iqJmlP0my6w=v@!92YZQ|*`m#;l43L1aIdj^!|`k%2cQi3VRu z)5_Ek;*o?ejKL|f#K3;ClJP1qHdUY0vgp#bjt4?$F7<{9gHhzsev9o`>p6p5g9PIR z^9J+sck}-Xj%yjo6{f*Iv{q#VVTtts(`>EGciB4f8tX7LLQ0(1AdWNOb9?N?UDVD^ z?6V1VY0J?%>I{SI-dsptwmGfF7On~KUJK8o4)n+kVEoE~w0{v0t{}i>vF9shBy0)Gy=wN` zi}M|vxYvK1VCgF|G4Db^O;A)K1B#yl=yg-a63VpaXFi18{CiRSi9lm{EE)vD6kg;) z9>hY*m+@aw`{&o__}nd~7(gq@p;N+S3Ao&~iZ;AfyVsvy)uUZ7nrl>l^f=@nb1nTm@C?=ECIzg1NH}9|%6fTY9Ir~YkpGx< zVhhHHx>i468kNY`P$RbcjccuwgLrbfD|*P7L36m5y!6g2>!E>M|7~gsrpbU_EQ3E5 z6$(9}gRVsRD^_EMAvQ<;RXz3(=Y(`HuyiqbQ5)~@EglEo?hmsrT~HlXGi4J`iZvR2 za2fzo;ntHATc25B_KR~wla3%K@yh=oF&H4}feR!(sO}x4v-qHv@CZSZoOTS9>)pen zI=m_cqcC}^8nW1G%r%qxdbhvkjBL!aeQQ&<+HETwuBl+}zv70}Q+}J&*3ODtcvqbt ziO4w2_2ruRSgu(7a1an^9-s~kG}mTiDJ!id2`)TeWk-`E6c*05KojkAM?Q9R_#P-3 zKI%op9ix(G?N_dWpn^stJPw8f!R{e{;+q{_SS1a1M#HYf0cHUUCLBiFj<=j>&nmEv ziSR{IJwhueH!)3WMLomI&JHPWzP`Aul6{7H>9R)kknt3iO;qlnuIA7?`Hda25w;XW z#~TEL1taPGPlHYI9#9(i;UNpuIjhD8rr?Gl%;A?7&ftZHn z2+n2CzPzin&tL8J<&eX(pwhXq(h6h6d_Z^&HeyriGu7(0+q8zwoR9(e@-ARx53v5s zrC_50uF18qIOVKbmU4NX0#Hg(dI1V@sQMubKLM&b{f3hloJ+5F$h z_ItxgP0iKwuOKx`UtW)^z*~h`dbD!j6n?bSAwqGvIlJ2fp;!AKP4Jd99&E8??bUMT zkf`9(pGOL$mYP@@qIvw0;p%65ule8cEf&QmwJmY6uUHK<-8|Xbe`OSZ1uHE^?WTGWv*5n|?B+jAWR&a7X*8=pwAmgFS@H2`|}CA_`mym_=D|0)`AmQJ@sr~%!Q1p zVpA6-gq>I~UF$KuLMJTq5w7OqS>ZczQ<0AxaxtZx3as6H_6oIZ`OZ0;?C_7*O&ycI zJj-pNKklUmXwz9=u_;wIw`IUs5WHW0sjmKTraF&@`jEwsl*2M-q}&&j`9>?t1l@p3 zR(JZ7kikC6i-Vk_)2`4JsI>kew|w<+D_1`1O1ByVjq>uUT~1*Uo+NVppDH=cvP>6+>SA_* zyjq)HZc&r79_|X;zF+h;>@$kL6{nZRDFH~O5T;$9v>WGrKHRaviZo3PR;BzjpJe)UZQ_LBdA^ z1Q3BUF3_^#{8Q?Udv!{25U|AqRPe;9(FuZS0Q*}GA)aO0?Qt$C8kK4D^!i}F4!S}q zP0Opmo)}7vk^u!z0N_W{{_f`ij&lNloUd|gbKXcP_UntH>$9xcFy7^Cac&1D@>8YE-e+)Ub6esXq?}DLtMWkXI9G?OmqSF z3bajmu||f01O-%etf?`GML{`YkYi7 z>A4&DIK5g3_~>e7@$(g$atZM3QpjU#i9RY%cT?T1S;Z9i*sw<<@Z0VNe%uI!F*x(` znE3xP;qsSaJuys@1Ll{EmN~QJ-x1r5oJRcWD)CVzuGmhX0JqXmb3RB6E-f;Yv`MzD z7ahUn4X;R2XAjTiDj(-_|V;*e+mnS8C#PGS}S2l*@c*8ty1cn-{j$dM9lw%XAaS($3)ptcQ*DXF}FQ;VLzkBoTX^f|3YMP(b zE0J@*v%Sy1{Bg?mdEWGBYV|ZA@an+bZsmg=FpmQ2@4j-j1pIlQpIIG#`Lr8)h4#t~ ztqW|lLhP9@@CB~fcOTV%7Jcj`b?23aD z{m}O+aDHP5BeV)014vZw&FfRh>F(+7SYn@p2@;0P^=rKtK~@w-XTm#flI!9|PPxPN zw-l@f-BfmVzy2@KLNlSbwVWkPzVb$ZEH@r-2ROvMZU+e84ODWo;I6ygKF|j zsLdIrW0Dc>6o$nd=hyS1i=*A~nw%}(*RqC~ci(JO!d6WKHhcEhvTZJ>{ok&~y8%xJ zXstI2i*t`Hemoc1N(O**fBD7GWiV$ZWQ#Y!}r^4!S;05gxuFuON)|<*3l=iKP-grsiVIIC}`(q zaGqJcbI!u8Bf~)T1^i=oAwkFgXG50&+0eV(FS`XkDi{|n8`PNNC=Mj_p&xqGqGKKf zE^4;V^OY8#U#2RqrM9yRK}+B5qNccFn@^^{$A5L&Hb-2I40)G>Gg=dWu$Bw$$a^Q$ zZ)qfHxrHDW*%fSn&>erm+hun0E@liqpHARb+bvudkRd%TjQQm@&m4&lAB4U>x;$yz z4~=xV+CzX3u2tTfxEwM&eUCtLKc(Wj-86^WGaXUkV3D5MvQVzZ5S#hUAdVg#G>MM~ zTCae@cg7;=NBD&rHWn@+Yq#Z#l(3|NKSlSky*2)Yw%rzb{B; z*QN66YM}bjmo0UZt&?`qwBC$Kr<(-1vo}aRP+61LsMJ9}9D2~1; zHh_!8oQrs&GO?Hg3&2X{0waaj__n3{0h#+Xx4lVT-5)IX*&gwdP!11`ky^|eto z1}zN(zltw&w;dwL0s5D#$BxWbcE`ER8+{=;XZg5a%#B}n=|3F|c&^a2C%!%*8U@_R zJC_^WbP9r5KyF8=BEbf;>6v+x`ZW;rm{9t2jp>s20O<^KS5jKxtJz1xTM$0Y~zABz4)ORKKs z+|S~|YIT=rX&kEv9N5`J*?A(26ZrlTY@&sn7>!OR&Ct2#PO+{CE7p(dRA7RldNko` zHGI^R56!T^g{7t{eU7l`d;d+J-rpr+_5j5YXHnxY(`U*A&DgxNtLF8a@kJt;#;U)H zw-e#OZCgPM0-cSy?vXRhZ!5s7hMWF_9F$qK$k3M%&yJ&Kx$Ooi3mm5~YGZ)vitdgx zVv33Zf@d^CEfwW&I>i)P^qM^|?2HdH6bZ2<+P0Z0FgTIH(bRGGgm6uZNwNIinhkuo z>;uNFr#;#HP4E?Qy`mejMXGIbo)>2mt|Q!#d;D+dC}cbA4{U2CjQQ zX988Uhh`~o8MJ)el7-$J#8ANmW~_n3T1&f|b@n~Nv-8P;3TubCNJ;0`U(0qL$lv8k zONuzw>kGC0=Smk9k%Xy#v#=jG+0nY2DemgHtL?WsJORYrweIOw(=7~s>RuJ*LQ-M} ztzU#-IKPe)V=B?tHntR{Isg~3-Mq57&P|6mR0D@i_JbBvIZ!&$KNL;PTkTjABy$BN zVkdOmljee9{ye?BkW=ZMy-)X`D67cUdn-8@m=c%WlMPjDz7;{ki%KdGsC)8;4xdV; z&BJEu{^$ytM|EKj@Jn3r6-$NPL=DS6?kD-%Xx6^a_Rrcy#Y&F_+OJ#XE>K~?!;6AC zl?SP@L508ETQwZ}>AG}Q_{!DH?+VJkZ=X^t3td39+9kvJR{#!v!kgc`kwZ)|AMGWn zdtX74P>vzG=KP;OF7MSmEH#Uc4Jmi$;3ND+-D(@i#6vDA+v~fv{_=pl^9i|oJH5T< zL{4&>bsiw<+>o0Fm8O&_}6Slma`ztZp1%hS*ls4_Cw=Ycclt{?YBWNl#PA?*~;c zJd<^3x<&bWPc>9$kOW0PF|aJsUla?o1^5Z{^kRe$3NQo3_(1jz(O+bx($biCVS%Q~ zEz5wJGX+&4q!`epFBPOz;<4 zpF7I%PV)z&pD$G@!&Hee#{hlY=CQ8Ldu^T2`|gRL2yW)muY%k5Hl$Kw2R-nuHxh1v zEqA2Xk^{GSTQAMtz)MlKaOD?wqHZY^ude~2)AP2wlMNa94WcZX)eZI5_We^qOB5f8?oHTNWPeLJ_LfMhacaZ}=uvnIbY95bj)4*D z^xoQGOl9_z^IgxGJG3L5FUgAv9pAQ7#sLW2g0#ICCnsBh5!_re(+QY}I(DWF9?f+P zRLolDX*S+C*jcY5w^X9>WXWwQfh|+(Yp5kQ5M2KXlmDAIq67Xx-bLYx2EHZDG&wt! zPg#^3idUqUX~y?<2i!iXKWk={9PgiAPb`j|u5K<;5k-;2)I2}S+wmel(9Df})Lf;& z3vF+mHmdb(a?~hdX{4lc1^-%o5qfy1-%dSL6Jmh0x2dl=*gQzb0YSpLA9dAnrzbQIcT=e@RJd2*kFEhF`AuwxEfthx6ruuglO^IctV8Pb zct1xRD~Bq9lVCdhy5d0hyLFVs4*ma%Z$M`P$b$!Zj+lP?hpR<_8WKg57e^-t4ZCv3 z4=<0GL#|tGNre=;Dhf7rxiL#z5>E#1-07dx)AmX?`vsRqX>TzN&jbe)JfFU3a<~u< zzETdibyKYK9`K%kCMKJt{?qio!Uj(W>*ZgrF3<|Ck=x@iHuy`wfA?n!lPZUf7I5V- z=xm%BBrC&PT&D&1WwH2IZ_h_gAzX=92nKVc!>z8>j@hzte=9l z+~WS`c>1i>epZ*PbDD2IvOnOR+4en$k-z8no!`B$uWxnhk)l z^lpq!US=-}>j3pflx{uqdqG0ob2P!tKt7na|2=c<^cjl(*Qf)~ARKew+gJ4?3 zGAyzvr?0d#_#y7d(+GKXhRYuzWD45Wpn6;AU?3yVnNb z+a4m;(~zn_iB;&3AnGSpjj+BRJ-tDu&=scwEIDT*GPEY*oB-m^F5Wdus65GlJ-Q&< zWLAC^4mH6O0Qpywo}ZzcBL6%OKW`vd*dU<&`s(-elADnwB|$C(Cfeu~s_}b4W#RW8 zJg;P^&mHt&7~tkP@RJ%uL>@Jc{EwUkI_CbJ+Yo)CXc90u4;0Z2yc5$t2kRQv-xuahC}{IAv;dx?sDHTaW!2`uT5V;Y>D7iXKhO zuvdT;*f`&uldcHf*~{zwh3M$e)3w}-_?a2J9IbfT#iP^0v$MP3z}L5hpsfh}P`;;E z*TblXjB`@gPx(#cznvLG_NMPEnG(#x?vk|ZQ{z4I z!<>B~n;mO$752;$pV+vn7pO+B#fG0c)t}e5Y=zRlZx)xGQ*IgT7gy2 z{Sv0isiipfWo)$n8)+Z@$bM2?%`T6j{SMuxYIV&w|ByT|J{`^Nicr@jYJUd}(w@)u z9DAps{Zm3ynjy%0i=7hvW6MNVex0DkTC`KefJR^haBp``$6pM(lC0{QmZInG)HU2jowZ zF}ikXQ1b7pSuz4(8UE_-E_d7WTG^t?PieyopilZ6?Zl)n7&+j>)>n1j6g8*@g4$T! ze2#{&^N0C;H093zDTPELlz6lUq#}*Aoz4UI^b64*|yCo7B8g}raW zaZF?LY8`OFSWmbe4gpTaw!{Fc9u%y9d|;3;!TSInHT=h_6NWF7$OiQj5;=&;3^;9tHaJkdh;Z_j1l%h|CQYcwF3t1;;GYhPg*xa><|aZZ00D(}IUVn4+(7R)KKcllR@%EU z*k2q^u)aD8i6>?8o4r)OU*DS$(3RWabK?&<`26m69xnVul0CVmqV?xVz_FT@GC5v= zLH0WBK@6GI`aha~p>oV6_seZ8hwRP1(Gjj3Xcj{WIQxe{-|u4u=dl`(g85T|$|GP@ zL^5Fhr%LEnT86jslZptH!b0}LU7JoGe7Y;j{5oY-D1Z9XTbJVn!;4GMYMz%k$lbAs zlK*weOQ05|5^NfgcnLUI&U?j)nUXWO`JSvec;ofu6`zG)fTLv5!sWGP!yS4AAywE3 zff5+l)`D(M+*28fqe&<^38dQ*5r6CDb%C=b`NrqaheiXLt8(?}?8cM@Cmu^tn{T0eXWpkAm<>fv2EmZ?RRookjum#57E~j4>-zO7=5BB zL;*L+ncK8%fk?#%pSN`)mkg%jZfC~KE&0vVVul;9ZDm!aMIxa?gF?$Z!DSKdoNJ@a zWo_o*h_qw!Y@VEr9LYS^Is$(Oy|d7JP;wXd*~^)55}{__$3|0(Jcz zs^-9?e@Osm3)tpllc)kwB@oc-uZUcs$ErdYzDzplMCL>Z(LaMD;yH}eXM|SMnG|^d zq6TD|QI7riC0Mi^7W07~!>ZjTlsuq%Ihml(C~|(-4)KMa0y9VPn0++FHFG*_i(OC+ zXqm24_ibmIC1^({`2ru&nY>J^VBIHhqRq>?<>>u*NS>zv#duCQ&ThCli%Pv9?MDRT z#pZ)Ya%#N^s`L^}a{pk&8mw@ovxE-dS!^V>G+l}S0X4WSLHzY>i6ft@gXvhhd_o>axeOnMiYG@8qQ&?vYLwBC%vyMZ$RI>FarBtMi4oJ}RvW zYG3m7EnO^g(R9fpn$Q!n!>@V_#Mwlo4jCDZbWbm^y~)2e+R3?(s@db(k%5 z4pdv z25EvZ*0-@I2@$LKlaB8bQ4z{Z{OY#>gJQDujY2Xw!Q+e@C7X%3c;$yaX+eTl@d%U7 z;4{hCBeK$J5`}c3{+b_YZAn92813kH`Cm*Kr!F<$bIUZ1*jzAYTf9WCofg}*7na8f z(!wz6B!4Ffde+}UsiE-fnboV*W5OPLtUzE=+K@adZj>kAi9LxSHl*C{%=%lnR!ZXC zx>s8d3`YeWAgKK8`ZBHo_Erj0G)sC7wak|J8^+CA({2zq&wUClJ48R+OBjs+`<8${ z-$uJLtsGu^Dc0%tMt*9_A7QkGg%f?NB?Yxv57GkoyeKxdHTC2imgzN$&Ce8c@J;jG zu1*APrBBPjToDhsaXF^?=bD=Vvm)L*iw;NuiVAxvA;r9P0ae!SrFg9YG~FTVhw`>U zV$vmH3M(JkbmHNH=gM1oC>>1mkVH%t;t2g5X+8=G?~NvxoWEUWUmK%0CK<&c^lQIzD4;?_qG0 zanVWage#sC7_YlnrE&S7V&z~u);c|~h$pyi}NxZ(ut;_0RJRoV4l_B#!A-)^(ZGPdh! z+QCxLBru<_?hf7)?l|0dY`1H(BK%9ZmC4jvcHHJ8p7*s9-ESOEYSD5LpG9Uy?<@(Y zq_}O34R4e)Oax_u^SO(m{)8?wC*Q`6Nd?0rpY0(2h~h^Z)ilmILAdSnA5glmB?ox* zlA60>{6qV{5{^1_C~|}#`8?LLD#I?@>8qxxs|#D^rZ&QAPMzso*lob;&E<2WUN*SK z8;`4J6H7x)>?o?%`#zs?Pw1P&#E)mlPqRbf#V;l%^C3g2b2Sd=&40IFYKbGFf%hmy zBhB@Ce~@p|*L#|bj7bk&47Kal3Hi{itBnEo9P$OuZaCT4=}E0f`W*181E;5Y!NPQ{ z%j@p-f6Z<`yIkclxA6Ca!obkJK(q#Pd@n=iG;}TvAZBygY{pzOOSHz zf;=`{?BMS`o4cP6+!{j$8UqGnocjjO+(KY1p)z9`Wm&kHpDs5K!0%@UhDMs1U8^@E zIq3;Qcai?SpsQLq0gF{$3*>`Ej-xuKR7%s7ygL zUXPe&0p_~~ z8O`$?JBP>6!hA@*=G^E0IQCo4t=&j&nc_N(C8pLeS|ocAa4ue~tUA2KRJFAM8q+O))#rF)cJjbE7GE0z!)bL__6GDY|@aX(y` zEIJ`qK2^3l2dE~KRQ|yVLbpbNgV3$4AmK19h%6k5Lb8E3Nq66qiQD3+zDcDp+&iTg z@%g=Z#<{=!IeVXQ=I+xKkm^bGh@EYEIlV285BqSsI$garHK(=69jY193pQYK0f)qb z7Lp(VKhozhilN30y#DA`sM8ucLixQ;Tg1q)S04W?x94dwKQ~`?lsJBfFY?;luE@4* zu)mSuAal+$H=bt13->YqmB8keaoK;z12yG^qO9!4=uk2cYj`xTj@4=8fVKRF-9Lht6x-V-0s#skBEu z-qlATj!35*;iQ(*;!?fB_RoZfkbMBBVp4%R?&LOA# z>}km9*6~Ec>Y^bcy#<~UBX?hQWo;Lo9ryqnqE0^+niN^%MM-}dhw~=A$)o^{&Vd9S z<4?XeClpE@1h+Xoq7dzW5)No#UY zZPF63=J<2V%I8LQS$)IPbbGPKSx@9+;^qZVp(s4VgivJPHLJ|TgrzS$(+hbu0<+N@ zcgT@D2#-3%zkz{o(AAYEZ50YhX>SnJ#QA*_CgbUD8|8F@p39B2$6?v=Ydo78Srlby zPTurtHvFu%>Yh|e5P=bGT^1k^)CKKTRs#Io*?9!LOMi)&2(Qwrh`ILgdu>%#WR6v~ zCuuG7R>Wg^fm_+M0JG@W?v9gPi!D zmg&=9aJwGp>25$10LZRZ=qbV%$SA2W^qZ^8u*fw0ZdtRcn|1G$$ZM<;!u8R%Z}Cti zB`BjI5krzs^taKH^*aD}T=)3ZK^q`c=!QM$^%OIUsL5R!F7Pu-MooNs>ORhWY8iw7 ztvvGTZj9s%d63;jje1luB#6#>BCB={5+#HB+u1o-M4fUfD-IQWILLkSzmC#h%DK`M z{)l?hEjQ=;OBJ%t$37;}6f{`60jTLe$1Y9Mgb<%ThF*$+pW90$F(c6!?{;yOIcRM@ zB^M(!EiCroH-E>S4RXIcj!86cx)vYW~|Db%^pbxaeftRS8PE7{TCSoSTs1bI&CZn-UjNA>(vQkM+*v~kukJ! z2vDg^iPNnpZ)5nDfSqe~#<$Ed=#aH`_>{eR0OSX1o<2EU5pZw{q4F0J3!yRa3i%8w9MY#Qjxa3$+{5f+*R@c)CQ~o+Ea716 zfz^O*XQ~#2eZ!Mi`pz}&^Os|uaHZ-`+8=%vMc+F}GCru!J@^!tQ9$wYCTG2JRgiHO zy7TZ6Y=#zs!Uo5S1AC;@#@WhIZ4d*SK2$@YdIvNBbT@suvv&z)VMH>2|Aokr?`5=RjCc-&Vv~@oOM(66R zSN{pB0#3fX3T5oB{?0x~;r}&u9`ICu|KmT`oz^uP5*0!slvT(mB_j=ml9B9PW@V%( z8dOTM(=a2FC^Lm3Nmh{^$|hU#e;s{>|Mxc@zx(KUU+4Wg=lwe8yw2-=?&;j?{kFIK znV=D*?4n4Xk9z_?J{I9zt^r#;ui=vzzG0v+yjF$Z)huTutDlFP45@RbUhV2l6L6L9 zmk{bs;Wj7imZjj6pa_*2yK z{nD!UxEp;r`_95eceC`CBlHI@Qo#-Bo5IU8ngzC`w=Qr#c z(!Is6iQu94u#Kw4$sQACdYxN0+n`W7#<=dy%MU^o7i(2bjTNhMRW_fK?X7FATGzN* zC8V^lrF-Aq-nO4>WBb;gFI78Odpi8*ZSDY{%E!YG9L;D!zZU4B%^9>cXf)!QX!FkA^v!BqJ+~e&rzCStR=3hNJD6{f6Q34= zTg2!ul)18Ih>s;UkBq6V-*f=Jk??1*%srAM#eTa)0Npz$Q02NoZS9Zx$^8|rRc%$?jLK6#T4a7J%?p3o=QceM^~5sQ?47sW zXRdc&2A>XCQ(ZkTr8WC(y-3{uaxPdtdg>udZtY906F)EC&$Ay|MtJPxIOHHNQT?;; zZLx}ASq)q9UdO^RAFgCmBRT55(zrO`eY-`ves_ePh$ww|FwM*1h;x8L$O_pj>2ivh ztv8B?$sB*4V~Y?`aYvN!pS`VPH=o_6_$6I&vQzu%wR%+Mhuj-!iO-h>Q~ASs*H$0g z+e_`IMNM$ONZc|X(@C(EGSgIDnY6!4!+dDCSzh@afc zDLBA!&*`zR`h&L#>n8T_QA-mo{7eIT6c{8=eY}-=uUUsaDHf+EVJG)1PIX+}a zhu<#bE$L-bRl9s-NtE@fwEfk;s=r(hHx1v|R?)=R&fA|Leahz=k#Fd^WY00NdqbuS z&nNf9%V>2zd*&}?rmsN0>wnTTsJ3yWvtuhw=UfBZ>6wwtVYSH|=d$f@qy>^zk#}a$ zVge4{m-`(!rWnakt|;|0$U?u^rUIpxZ4A{F_A(V^N)!oBkq+2awVLzg2D9bSRi8|> zf`-=DSr?tHZ$6{yHJz8C{AuphVhOUU9 z<7SCOrp*3Z`chXMs!zh#Oe1CPwBGH96^?}r5?2$<#PfIqiK|}ovP!qrUHU$D=;QlL zLHUa;)++2Djq)PT?;89i6gDck%AUVwq$;KLM*1$r6QVn^@3*Th>HS$u@0f zMe~eijBi_Zrq%o>*O~ckQ*v*4AAf%KDqn2kB|Xqwq{1@Vs%X+Wg%Fjge8M!`IlH(h zH)!>A%-0*+2ETQ0{HS(xV%{d4D1GIwsHbXIpY7G%Ka6`M*@-s;NpbNG!N1y^{QD^) zohlb6kCH7T4&PAyAf#ww+ukq+s!X)1M#A^uTA!^Rw+e?&zq{*x@Wz#*6GsNl zvYT+z1}=of@HdWTmuAe>b=F%h5WV{B>YHd`1=bFcV+YUw-o7b}@Ab*aGn;gBDoTm2 z6DMD~6K;yeIr2|AsR>C~%XOV%8W?_&5Vg~J^6_;qS(ZCfl2H&jwd~xlz`Zle411ZH2V#&!L39#U{U>R>Xe2#5VwXv>CK~v_NqtuS#W#fF|0p~BN3I>V1z3WA&oh&k48Gh@1V`B_G zg`jy{dSY#G(L$GeQX_>*+O?}?M^jW_(lM_Wvgc1f6S*In`!FR*H}7`XmWS^LVsEe$ z@6vCqg+C7WD~9Jo4=t1)^|#`Y(K7KY5lFl#!XSHFjeV?R_)Mt=SM`HJZ#im0eW4e# zmR;wkOLgb`6oWj*!@s+myuYaM;8N`hIi`0X7`JHFb9rt#BztGVNaoJv=FeANHYzT6 zKRK+uQEGvA*S+x68WY&(jJki3_Bw3$3 zo>h(<*U#}--A`rajIl15tIEu8RT%m(`}m8fSS4%4q`lxJe|C{Js?k`}og@QMGlc`w z#bzR#wAnT)H0_r*oV&)`HMfeo=636)I^ILI91DR-%1^p7CtS*cjr#p$PmXUt*ql|C zc6omNb%FH8HxxflzX>#sis?7j+~a@zDWK-q(bCGOy4f{)LMIu!%vYs0zaM*x2Q_D)c;-EDR}IqeYXFa)gxWq!#~$Ow;u@4nb4i@)_mZ7^Pv-W zXo~w}eJ@XBDLjq7=T2*yz0MZ(?1W37%5@BA%+>rt#oyx(x~w?Q<}=nn2vk2)oG~ut zXWP?Gsck)<%`~muuaYOG;xW9{A|6Z78(vO$gNxCvq$ZL{h@0J zin_BUX>-kkJ-5E-b!wRJ;HeMOG-5V*H(dCB?La`=rtr4XY(Ei0WhHM-`_;24&#&|v zCcynvY(qy&b4aY8<76OGilX~l218aernL zt`Vumy)(6w-Gr~!ZQ38@CcY00c8MqMHcFs;*vK9y_qFlPvU>*7++X!>ZC*bse6(-B zquM;fTgs*KiS3>RJ`pNa3ugI_L0{RsZS+>VwMAZ8*0H>-Sj=6GvTI%Y9y~X}pGe*0 z(Cr%(N*ho-YTc7@<+cJPIkDhNR^*R$6D4Op*=K!@zg4b#)cB-i^%FC#-hLj(Y{%Ha z5LLO^8XUq|vKGP0ch za7ZgbEc}Mp9`C1(z9N$@*Fy9nKR7;!P>*~R)Uq?%y>eTmZnx;k$F{c_I%Dq{zPC|c zS^D6`t$xy+Tl@|cJ(TsL^ek^ooQc7$xs}st5p4zU&3sdZE_BoF-zLwU4eR8v65!um zIWI8te#McH*F1fjFLD$3lWJ(ize9&BtX6scgq79c69o|?Dw z8ouhTaN*(*Ln)Vs%aKB7LP}PWUd$QGEV9zKlCDS^-)%RuvzwIGu4xHryS}oqD&x+p z$Q2C6zNgm)+z%6ucF|}R+TEADH=d#-pLDr8W-wy!(VN*~A(yS)-Se|=I)*nrAD`u|DmnIZ zMR>|w>jt$D;Y`C9MSHF@cvlqZNo2~naG!si_vW)sn6j>CZfnpQ4#n$mkFKV^X^k$Iu12=?BVXKs9Vc;YE^XBrl%)6c8@$t5}k9rNjR*{Ag<84?J7Af_Fc)& zDD#~fTkY+`Qofc)1etvCKkCWHnLo0E*)}6ZXi!U#)_W3}8oT=$>uFr!_k3CRqvv3v8-U#qE2?GWvw{j});oA9m_ARITUd zX#IZYhYa(}djTb`_Peh-k4nvbBBoxL@Yt`ewb|DpIsRq$BcD^gqh7=JUGgn6#dELv zu!Tuw#vkw28~b#^ob+*{cqiW|`+lW{vc0ZOuENL4kdT2D^V7i8P?rp^X+=ho0phwX zTu)Arrl^>`cNzbl7rX5(Q&O*!Nz*-tPXSdtI+_yg2hJzHcpd-5#v{t0^T@gTMg_+r zZH=V0<5Qkba{I_VFOe1%yQw+WpS{dZTJYyC|KULMlrQOewoSjAk6BU=l|J9*Q7)X7 z?R;!^-zTvk-c^?auWu{}R}Pz3B&MDUw>s-%-!gbB(fRb`lueh+r}B`sN*)H*=OdI| zeTDZeyoomGP&k%EAQm`n7^^C_1#?`3;9R1T>p3C)_HNO_;0nDkfP#N4;DJD zGxir?8(Hs1SX(EekDr84shUwkT0x6WPFJ)wOp=sH*_C$qc8y&o`O~rS`W8*k_R-&t zlgbQ&v!>#A7OLaMOjWMMadylZNc84aOQhX){1PK?mZV@;_lEEENLJoLq5nsthiMW% zTzl8N&#HQ1);e@9qVfK9duolTY-ii-(Fqp?3+Yhm$<`;Y{io*TCa-^wRd+Kz8DDTO zAW|nVtyDMvobs+fp-N?`({(qrHt3%SbG3Q<>+3fg*xx&Nm@CK7kZfk-n80`0V?)H8 z&}M!+wxWe|3%|nPW`E~5x9^49STmIEWIymwknv(fSq}4V{ii)9yTa^}52V`cor|kh zI%}LY-8@^!>mB@lpH{<=rBF4+SoD$k^dslJU<(!Krl7%_mEAHI;L|fgeyNT}>-EwxLOyb#v zIoGt{^ev1bmwQC4>Yd8o16DiM5l%1E6i|OB`;<8@S``D+(mbC z?zFs1(Kp(9;%NTa-{ss|5j@TPy-uH5zbkdS>`)e`SF8=VXD~bXq?zOcPnq%ic;tYb zAvxeY?6rw5XTP{>$L64;W!(+44O;py8uH!3cZKO@>sl4Io0J|OZkzg1ch0n}W?&(j z$;6h`V9q*w`ssr8!_>2TzEHGhPxX!bK6do9$Nrv$PaVRn#%l(L6Jykx4Zaq&zHnCZ zjyxkEop-~yB7hpwUqG=-^s33ItzW(WR@=Q#y3vfB?Ss#bd^WtVoEPECF2MRgc-l*R z-KO0;#?|$V`=`F2KUO4I5Zx_a|1ed)Z;H8bUU=JKbumkBm$RqMFV2n^p2)wgUz6mg zXJE{~e7%)%{IWHl6@NwU8hTb7oFseBcV=PaqdF@S{;QmO@Rb+`&5VORK$w&9qI+Rp ze}uc#;dHJxp74W0Eu5y?NlF!~d411SnJ?sXc_`Z}9_zKuj@kbFk)7Pygo#J%YFBK| z@)6r3xxw7gQ7%fUYPBz`%7!PJN8_eS6&_XksAMwhbtL9{u<~X+P+#ff^-5E5xuo;t zVdWa_%|of_bAF|PrJQ*gDRy!#q6vjnt#_!gk*ReC;q#`mrd<~ys9vSMCaFE;lb`98Zo_@e zr_B0{+hW$m95gOYU#Fez{3-2w|LE$(p8h*S3rd(Fo*4G}Gt@e7s zzpzcRa{k>AQ?#Murqyd2TQ4;DS+<98=|3gOtIwV>HjxyNebhHof5wQ>>gbkBj5eAx zHeqjad177HI&CLX)Zqr&1U|U`tH=t*K!PdVv*;O<+_>bl^{Ss2Bwz9chh^-%%pgY) z-O1vz*5yQVN;j{=HS_71!>jI{J#8a55N|3fH}%+#GPKq7c;7RYO#xiJw8~%BLCSuy zOv$lA{M^rU^jja+M4UYiKM01M)rEz3M{EwLOOfFx+kEh&dVZ4n4i2RWclkbJyU3Q^ zbyXa~-2&;)IDG8Kxu2L6MSPyr4_*j&9iH~KnX;bE-tLuS$Hvq%Za)+LtG!I8KWC&# z{@Y>W?L`~)Q*MQfmj?&y3P}FS8sI%P&^C0&+cL9VWXD^HzM%7o+t^~A1?%61ID0o8 z8-KaadaPvmP60kFS56arz#I|)Am5N?}Td^ZTb``hV% z{Kf%cPFg?!Kg!EZS8RAu_l{FkE9i>;jY;Zn>vxXbL;4YIMdb%Cw@mqnawuO?h?lHe zV9N1u3GSLFr%5_1$;fb<&7CcOy|=0OVc&z!^=e9c-- z5${GOjyT%~{dv|XLWt&QVdjR=>S;%3XAd}baP+XT0ExVXGW@DXjPqb)_B%qdeu#kZ zzkgr<-vOxq<5>0IlK)+b2vy&9b1}1rOd~h@#X8w=ofy#lWlh%nznuIZ_55A(?{cd6 zWc)S)AS7sM;pPgG*V4fi`nmW53xC;Nv_)faw6HaU96m>jfBXKY9owP%GDz>u;(6jW z77i|dOd#Z8Yhn3!8cTgRcGfU+vT(4(WvDrhwoX=%SnlDtXmn|_1ug#M@mjc8;xhP! z^I6%Wxvqh9j9 zYGDD6#KEc>K}VH!p#!b72H8ZW4vvLaE{5~f;YYjGkoE<=Df9`hF@`1p@(6G)5hQ~M$a{av5Qo9mVK~PK zB;?VaK>uIEx%rUK1!+mhgT9j(A)Nx}nZYq!qa;oM+D7{T`bKEiI;b1Yr7ef_eJC>n zsDT}QC|3tS9~f4F&Kt^4!ZG-b911c9%5;OTKS3VU$AtA0a11tCq(KKhV|D`x?O@`D zG}x!KK>7eksGHdW`c8xLbnt66*iwM=v_L-vX|Tyy1Lerju6{U27;O7Nni=YS4C#vq ziEc#*od$3`>;sMha6SS*q7#54@E&|812Pq)707guIlxVT3iJT7z%2A}4M-P|FwYR; zK(>R=;z3qGojxF;J;Y{6^FTTl_%?m{{j@*Y9Euz%P=;&x_1ej(^DfxHb;5qJve zb3ixLF$mHa;DGcg0OATwgM^qMXhRytfe;BmyBXF3Fph*MkPwFieBMSt4$?4Igey?~ z8A98jULx2c!I)B^FQiUr%MS1(^qU0!ra;^gRe)WtZsyK#4t~KkL|TuKO(sH~st8?7 zMCcBTVJi5y_#i@!5Rd(Jh(M+z0=GFLNO2*;-fU=AA|m)@AwqZ?B0OD(2$k`O(2oqY;DoO~l|Aix?udB8D6SVyF{G41<}7$o2{mMJ5n&w+JF0&qKu1 znuvI78zQE1B4Q~cB7Pe{#JR(Ww6YtKB$gwR<{s$64Mg%vK&0>ui1Y+x#bZS3zK=-L z3y8doACcF4A+mxyBI~px@`(?K>?w=Lfe#V+P8lLU3P9wnR75VRN92zxh}`)Wk$>hQ z3h@b|tlEhvqDK)$`68m|-b57hb_n;^h!UcLDEFigB})QP-n1afXC_4X9*QWx6cClO z5m5!55mkN!QTK);>WL{tJ?o38Awh`xZ~;;C9wF)naYXI7jHqL}h{o^((byj%nm`+( z$><@P1}~x=U59A4M-k2YE24#}A=-llcu+wTqE(b5+BbGYo9l$jV;5o+97l|@vxreg z6fwd&l+m*SG2ZAyjQ70|xIEOpS=YsmfIeP<>3Xy^7Ia3c{PSu8s8$8-g?9`qm5XpC5V;lK4RS&P!eM{g0G(-F}H+!1~A zT|`$th3I=l5Zy!q(e08E-QyFY`?Df?NCKiqwjz2mC!#;MMD+6Kh+fBq=p8E&eefHi z&)h_8t&$RKCL79oS!Qe6>S(Ewumcmc6>nZW13jsIm$!hkQV z#kJ6%1b!zF7T^C>ET$Hf|LRo#P2$x5v?lReEVg(q2dvBfr!`5QA7Y~Yw`&qtXLqMX z8-KkWf@6z+*Cgy&f2~Q_UH_~}{x17}SdXy(y&kdtkM)Rw<4GB_zYP7`Z3YArCPL6u z{F1hGwSW&Tn4~D8_!52W5`Ekf zeZmrb;u3xO68)1U`sYjZIZO0~OY}uc^kqx*oCKW-PSxrpE|VlkWp<4k-3z?y>y zYYh1Ogo_op2s{E_0k9UqYaL=TfbEw8ZvZ%d(S9~aX!l|s_=(s9KpTl;0Im<#KEz%C z_ooZ$gliM~i}(YW1NuP63$gdsxpcwjZ}|2=p+v#08L0 zfW!)b|8bk~dYmKzU^`p@e)czI&gpSIO6!gc5s`>f6C!?|Kix=ao2?OJkSro z+7Z`9{0!h2>IS|71AkHSm&ExaP*xrI+dsICn*m%u_Lmr737iIOfn$IypatOZhdwU) z2>10Dq_KZ+TX8($n8^ol45S0#3t|G03OoTSfKn)nZ9zSY?T2_@96x4|*r!qe_6xLg zF;<~(i!p>_^CN)c;yHlh7{^UBfUgti8_5hf1^585fG+@kA{hg!zy?4L5C(8xaefF8 z2{;1rfIDyz&;7v~4;UwkgM+X7%)c&xE4JRkfG z9&0@A4*@)0xEzejqMcHZI3BSNngHAeEOET}1M`3ja2kMqlGFfa0Q=Yk&;#}YbjZgx zD*zk^xD7asZ8!qhUL=6~RSc8^UjW>e8UW|xy0Pyt&}V#Y63+m!kUkE$0=ofRC)g!D z0>Czojm0@`Gf0S`#s5!1IU>XW5ld(v(G$SsP67@9#1HWra2>b+;I<_J_kmOZ`waSk z=d#6d{|f0i0JlLF&;TL;e#pl$0r9dJ7f^n2et{Sw?Evt#>I2~Tzm4#s36Y?GB=8mf ze>`a#z;hCgH>?L+WUxtI2k9R`7o?d%-U7A*Oi)h|&;V2eI93XQZUDy_KK=;Q0(d;| zHJ=Y80yltQ;2sbSWPrVm0QToHIDQJ001dBES-HE!oy$m0OO4hd{7wgI=p0swz3UKa;I;&I~vc!8?`_>2VYA!P!% zA7F<}25Aml1mb}gpd$iXfaP!uzQOxEr1QXTU>^X!Ch-A`fF_^{U_G2eIs$;-7e80P zZ;RI%?$>ny`vi}hJ&*+8`1b@H0cii?SmHeJC(#H1pDlh~J3+b|7y~*0JZ9L>y+AR5 zV-NcnUssj@)?Weq{*qv4(Z{|ZT>%>a*Wm&7p|99SL~r0Ufa?W+5KRC*0JjI*g0|xE z#&JR-1K2k-fCY58z7a^T1vv|{2LK(Z3g7~8S?XWuH6SGcMIai$u?p?Ma~j^${AVxb z12}IpY;#v4WDoIh2FhJsiBJ^y@fpk+m2j=Jft=Zc2&_GbAZ&;TJA@IzKm`%pqY&X5 z?9U{+AwuyKA~b{{!iY3tpl(DA{M?8^WehPG{6q|HJ%}OrIATa$jTqjN5JQJ9Vpv#( zh`d&axNQ^>58s6;YbS)PB20zf;j#c-Ju@N>pMmM19j2ff_!y+YkUK#L`a_6G!c>(8 zAvT9dUrr&?pco=k{Sld~1Cb?O!S>TGMAkJyWIJ(0J`4LVS9}pU;u0b!dLnX;JtDuE zN95WzMDA5b6h-m3g5Ur>h(duFmZA1+*GL#}l?p(wu z274~5%MjxcWyI*P8Zlk~!a<+bh8WAm5M%Qz#5fLnF*JGDUY$iu+kPV^-6q6jRf3p2 zU=QZzV~I=kp-ux08sueiLHu+=-aS4k6~>>WGD<0I_h3A{JrTcahnQ;8~}L zW&cgYVj_ZA9L^&a?+u9M@-Sk#`xCJw9zrb7Pa&2vR>aa|g;)jz5zDU?h?T_<1qUYKm`nzR_-k^%; zU3(FIcmtx(@gOz|Y?W(1LTm;)h|P96V)NdI*n$rtwpe$>_LLv7y*Yr`8X6H>PYhf) z$K5U5%s?J-v|N;sMEpy?hk<<(7qgQm9dO3qFEQIcU-qm|c5{Zw2=TyoT-XQ!7uaj& OVI?aqAtfUr_5T3)u@DIW literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart new file mode 100644 index 000000000000..e218d908729c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart @@ -0,0 +1,1415 @@ +// Copyright 2013 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:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webview_flutter_android/webview_android.dart'; +import 'package:webview_flutter_android/webview_surface_android.dart'; +import 'package:webview_flutter_android_example/navigation_decision.dart'; +import 'package:webview_flutter_android_example/navigation_request.dart'; +import 'package:webview_flutter_android_example/web_view.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const bool _skipDueToIssue86757 = true; + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('initialUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter.dev/'); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.loadUrl('https://www.google.com/'); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://www.google.com/'); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl with headers', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageStarts = StreamController(); + final StreamController pageLoads = StreamController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarts.add(url); + }, + onPageFinished: (String url) { + pageLoads.add(url); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final Map headers = { + 'test_header': 'flutter_test_header' + }; + await controller.loadUrl('https://flutter-header-echo.herokuapp.com/', + headers: headers); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter-header-echo.herokuapp.com/'); + + await pageStarts.stream.firstWhere((String url) => url == currentUrl); + await pageLoads.stream.firstWhere((String url) => url == currentUrl); + + final String content = await controller + .evaluateJavascript('document.documentElement.innerText'); + expect(content.contains('flutter_test_header'), isTrue); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('JavaScriptChannel', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final List messagesReceived = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + // This is the data URL for: '' + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'Echo', + onMessageReceived: (JavascriptMessage message) { + messagesReceived.add(message.message); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(messagesReceived, isEmpty); + // Append a return value "1" in the end will prevent an iOS platform exception. + // See: https://github.com/flutter/flutter/issues/66318#issuecomment-701105380 + // TODO(cyanglaz): remove the workaround "1" in the end when the below issue is fixed. + // https://github.com/flutter/flutter/issues/66318 + await controller.evaluateJavascript('Echo.postMessage("hello");1;'); + expect(messagesReceived, equals(['hello'])); + }, skip: _skipDueToIssue86757); + + testWidgets('resize webview', (WidgetTester tester) async { + final String resizeTest = ''' + + Resize test + + + + + + '''; + final String resizeTestBase64 = + base64Encode(const Utf8Encoder().convert(resizeTest)); + final Completer resizeCompleter = Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + final GlobalKey key = GlobalKey(); + + final WebView webView = WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$resizeTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptChannels: { + JavascriptChannel( + name: 'Resize', + onMessageReceived: (JavascriptMessage message) { + resizeCompleter.complete(true); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 200, + height: 200, + child: webView, + ), + ], + ), + ), + ); + + await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(resizeCompleter.isCompleted, false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 400, + height: 400, + child: webView, + ), + ], + ), + ), + ); + + await resizeCompleter.future; + }); + + testWidgets('set custom userAgent', (WidgetTester tester) async { + final Completer controllerCompleter1 = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent1', + onWebViewCreated: (WebViewController controller) { + controllerCompleter1.complete(controller); + }, + ), + ), + ); + final WebViewController controller1 = await controllerCompleter1.future; + final String customUserAgent1 = await _getUserAgent(controller1); + expect(customUserAgent1, 'Custom_User_Agent1'); + // rebuild the WebView with a different user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent2', + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller1); + expect(customUserAgent2, 'Custom_User_Agent2'); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('use default platform userAgent after webView is rebuilt', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + // Build the webView with no user agent to get the default platform user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String defaultPlatformUserAgent = await _getUserAgent(controller); + // rebuild the WebView with a custom user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ), + ), + ); + final String customUserAgent = await _getUserAgent(controller); + expect(customUserAgent, 'Custom_User_Agent'); + // rebuilds the WebView with no user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller); + expect(customUserAgent2, defaultPlatformUserAgent); + }, skip: _skipDueToIssue86757); + + group('Video playback policy', () { + late String videoTestBase64; + setUpAll(() async { + final ByteData videoData = + await rootBundle.load('assets/sample_video.mp4'); + final String base64VideoData = + base64Encode(Uint8List.view(videoData.buffer)); + final String videoTest = ''' + + Video auto play + + + + + + + '''; + videoTestBase64 = base64Encode(const Utf8Encoder().convert(videoTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolicy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + + testWidgets('Video plays inline when allowsInlineMediaPlayback is true', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: true, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.evaluateJavascript('isFullScreen();'); + expect(fullScreen, _webviewBool(false)); + }); + }); + + group('Audio playback policy', () { + late String audioTestBase64; + setUpAll(() async { + final ByteData audioData = + await rootBundle.load('assets/sample_audio.ogg'); + final String base64AudioData = + base64Encode(Uint8List.view(audioData.buffer)); + final String audioTest = ''' + + Audio auto play + + + + + + + '''; + audioTestBase64 = base64Encode(const Utf8Encoder().convert(audioTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageStarted = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolocy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageStarted = Completer(); + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + }); + + testWidgets('getTitle', (WidgetTester tester) async { + final String getTitleTest = ''' + + Some title + + + + + '''; + final String getTitleTestBase64 = + base64Encode(const Utf8Encoder().convert(getTitleTest)); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$getTitleTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + final String? title = await controller.getTitle(); + expect(title, 'Some title'); + }); + + group('Programmatic Scroll', () { + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +

    + + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + // Get the initial position; this ensures that scrollTo is actually + // changing something, but also gives the native view's scroll position + // time to settle. + expect(scrollPosX, isNot(X_SCROLL)); + expect(scrollPosX, isNot(Y_SCROLL)); + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL); + expect(scrollPosY, Y_SCROLL); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL * 2); + expect(scrollPosY, Y_SCROLL * 2); + }, skip: _skipDueToIssue86757); + }); + + group('SurfaceAndroidWebView', () { + setUpAll(() { + WebView.platform = SurfaceAndroidWebView(); + }); + + tearDownAll(() { + WebView.platform = AndroidWebView(); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
    + + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + expect(X_SCROLL, scrollPosX); + expect(Y_SCROLL, scrollPosY); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(X_SCROLL * 2, scrollPosX); + expect(Y_SCROLL * 2, scrollPosY); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('inputs are scrolled into view when focused', + (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
    + + + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.runAsync(() async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 200, + height: 200, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ), + ); + await Future.delayed(Duration(milliseconds: 20)); + await tester.pump(); + }); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + final String viewportRectJSON = await _evaluateJavascript( + controller, 'JSON.stringify(viewport.getBoundingClientRect())'); + final Map viewportRectRelativeToViewport = + jsonDecode(viewportRectJSON); + + // Check that the input is originally outside of the viewport. + + final String initialInputClientRectJSON = await _evaluateJavascript( + controller, 'JSON.stringify(inputEl.getBoundingClientRect())'); + final Map initialInputClientRectRelativeToViewport = + jsonDecode(initialInputClientRectJSON); + + expect( + initialInputClientRectRelativeToViewport['bottom'] <= + viewportRectRelativeToViewport['bottom'], + isFalse); + + await controller.evaluateJavascript('inputEl.focus()'); + + // Check that focusing the input brought it into view. + + final String lastInputClientRectJSON = await _evaluateJavascript( + controller, 'JSON.stringify(inputEl.getBoundingClientRect())'); + final Map lastInputClientRectRelativeToViewport = + jsonDecode(lastInputClientRectJSON); + + expect( + lastInputClientRectRelativeToViewport['top'] >= + viewportRectRelativeToViewport['top'], + isTrue); + expect( + lastInputClientRectRelativeToViewport['bottom'] <= + viewportRectRelativeToViewport['bottom'], + isTrue); + + expect( + lastInputClientRectRelativeToViewport['left'] >= + viewportRectRelativeToViewport['left'], + isTrue); + expect( + lastInputClientRectRelativeToViewport['right'] <= + viewportRectRelativeToViewport['right'], + isTrue); + }, skip: _skipDueToIssue86757); + }); + + group('NavigationDelegate', () { + final String blankPage = ""; + final String blankPageEncoded = 'data:text/html;charset=utf-8;base64,' + + base64Encode(const Utf8Encoder().convert(blankPage)); + + testWidgets('can allow requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('location.href = "https://www.google.com/"'); + + await pageLoads.stream.first; // Wait for the next page load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://www.google.com/'); + }); + + testWidgets('onWebResourceError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://www.notawebsite..com', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + final WebResourceError error = await errorCompleter.future; + expect(error, isNotNull); + + expect(error.errorType, isNotNull); + expect( + error.failingUrl?.startsWith('https://www.notawebsite..com'), isTrue); + }); + + testWidgets('onWebResourceError is not called with valid url', + (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + + testWidgets( + 'onWebResourceError only called for main frame', + (WidgetTester tester) async { + final String iframeTest = ''' + + + + WebResourceError test + + + + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframeTest)); + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,$iframeTestBase64', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }, + ); + + testWidgets('can block requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('location.href = "https://www.youtube.com/"'); + + // There should never be any second page load, since our new URL is + // blocked. Still wait for a potential page change for some time in order + // to give the test a chance to fail. + await pageLoads.stream.first + .timeout(const Duration(milliseconds: 500), onTimeout: () => ''); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, isNot(contains('youtube.com'))); + }); + + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate); + return decision; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('location.href = "https://www.google.com"'); + + await pageLoads.stream.first; // Wait for second page to load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://www.google.com/'); + }); + }); + + testWidgets('launches with gestureNavigationEnabled on iOS', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 300, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + gestureNavigationEnabled: true, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter.dev/'); + }); + + testWidgets('target _blank opens in same window', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('window.open("https://flutter.dev/", "_blank")'); + await pageLoaded.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter.dev/'); + }, + // Flaky on Android: https://github.com/flutter/flutter/issues/86757 + skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets( + 'can open new window and go back', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(); + }, + initialUrl: 'https://flutter.dev', + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + expect(controller.currentUrl(), completion('https://flutter.dev/')); + await pageLoaded.future; + pageLoaded = Completer(); + + await controller + .evaluateJavascript('window.open("https://www.google.com/")'); + await pageLoaded.future; + pageLoaded = Completer(); + expect(controller.currentUrl(), completion('https://www.google.com/')); + + expect(controller.canGoBack(), completion(true)); + await controller.goBack(); + await pageLoaded.future; + expect(controller.currentUrl(), completion('https://flutter.dev/')); + }, + skip: _skipDueToIssue86757, + ); + + testWidgets( + 'javascript does not run in parent window', + (WidgetTester tester) async { + final String iframe = ''' + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframe)); + + final String openWindowTest = ''' + + + + XSS test + + + + + + '''; + final String openWindowTestBase64 = + base64Encode(const Utf8Encoder().convert(openWindowTest)); + final Completer controllerCompleter = + Completer(); + final Completer pageLoadCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + initialUrl: + 'data:text/html;charset=utf-8;base64,$openWindowTestBase64', + onPageFinished: (String url) { + pageLoadCompleter.complete(); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoadCompleter.future; + + expect(controller.evaluateJavascript('iframeLoaded'), completion('true')); + expect( + controller.evaluateJavascript( + 'document.querySelector("p") && document.querySelector("p").textContent'), + completion('null'), + ); + }, + ); +} + +// JavaScript booleans evaluate to different string values on Android and iOS. +// This utility method returns the string boolean value of the current platform. +String _webviewBool(bool value) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return value ? '1' : '0'; + } + return value ? 'true' : 'false'; +} + +/// Returns the value used for the HTTP User-Agent: request header in subsequent HTTP requests. +Future _getUserAgent(WebViewController controller) async { + return _evaluateJavascript(controller, 'navigator.userAgent;'); +} + +Future _evaluateJavascript( + WebViewController controller, String js) async { + return jsonDecode(await controller.evaluateJavascript(js)); +} diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/main.dart b/packages/webview_flutter/webview_flutter_android/example/lib/main.dart new file mode 100644 index 000000000000..6176ce255eb9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/main.dart @@ -0,0 +1,344 @@ +// Copyright 2013 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. + +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_android/webview_surface_android.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'web_view.dart'; + +void main() { + // Configure the [WebView] to use the [SurfaceAndroidWebView] + // implementation instead of the default [AndroidWebView]. + WebView.platform = SurfaceAndroidWebView(); + + runApp(MaterialApp(home: _WebViewExample())); +} + +const String kNavigationExamplePage = ''' + +Navigation Delegate Example + +

    +The navigation delegate is set to block navigation to the youtube website. +

    +
    + + +'''; + +class _WebViewExample extends StatefulWidget { + const _WebViewExample({Key? key}) : super(key: key); + + @override + _WebViewExampleState createState() => _WebViewExampleState(); +} + +class _WebViewExampleState extends State<_WebViewExample> { + final Completer _controller = + Completer(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter WebView example'), + // This drop down menu demonstrates that Flutter widgets can be shown over the web view. + actions: [ + _NavigationControls(_controller.future), + _SampleMenu(_controller.future), + ], + ), + // We're using a Builder here so we have a context that is below the Scaffold + // to allow calling Scaffold.of(context) so we can show a snackbar. + body: Builder(builder: (context) { + return WebView( + initialUrl: 'https://flutter.dev', + onWebViewCreated: (WebViewController controller) { + _controller.complete(controller); + }, + javascriptChannels: _createJavascriptChannels(context), + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ); + }), + floatingActionButton: favoriteButton(), + ); + } + + Widget favoriteButton() { + return FutureBuilder( + future: _controller.future, + builder: (BuildContext context, + AsyncSnapshot controller) { + if (controller.hasData) { + return FloatingActionButton( + onPressed: () async { + final String url = (await controller.data!.currentUrl())!; + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar(content: Text('Favorited $url')), + ); + }, + child: const Icon(Icons.favorite), + ); + } + return Container(); + }); + } +} + +Set _createJavascriptChannels(BuildContext context) { + return { + JavascriptChannel( + name: 'Snackbar', + onMessageReceived: (JavascriptMessage message) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message.message))); + }), + }; +} + +enum _MenuOptions { + showUserAgent, + listCookies, + clearCookies, + addToCache, + listCache, + clearCache, + navigationDelegate, +} + +class _SampleMenu extends StatelessWidget { + _SampleMenu(this.controller); + + final Future controller; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: controller, + builder: + (BuildContext context, AsyncSnapshot controller) { + return PopupMenuButton<_MenuOptions>( + onSelected: (_MenuOptions value) { + switch (value) { + case _MenuOptions.showUserAgent: + _onShowUserAgent(controller.data!, context); + break; + case _MenuOptions.listCookies: + _onListCookies(controller.data!, context); + break; + case _MenuOptions.clearCookies: + _onClearCookies(controller.data!, context); + break; + case _MenuOptions.addToCache: + _onAddToCache(controller.data!, context); + break; + case _MenuOptions.listCache: + _onListCache(controller.data!, context); + break; + case _MenuOptions.clearCache: + _onClearCache(controller.data!, context); + break; + case _MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data!, context); + break; + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem<_MenuOptions>( + value: _MenuOptions.showUserAgent, + child: const Text('Show user agent'), + enabled: controller.hasData, + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCookies, + child: Text('List cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCookies, + child: Text('Clear cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.addToCache, + child: Text('Add to cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCache, + child: Text('List cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCache, + child: Text('Clear cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), + ], + ); + }, + ); + } + + void _onShowUserAgent( + WebViewController controller, BuildContext context) async { + // Send a message with the user agent string to the Snackbar JavaScript channel we registered + // with the WebView. + await controller.evaluateJavascript( + 'Snackbar.postMessage("User Agent: " + navigator.userAgent);'); + } + + void _onListCookies( + WebViewController controller, BuildContext context) async { + final String cookies = + await controller.evaluateJavascript('document.cookie'); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(SnackBar( + content: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Cookies:'), + _getCookieList(cookies), + ], + ), + )); + } + + void _onAddToCache(WebViewController controller, BuildContext context) async { + await controller.evaluateJavascript( + 'caches.open("test_caches_entry"); localStorage["test_localStorage"] = "dummy_entry";'); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(const SnackBar( + content: Text('Added a test entry to cache.'), + )); + } + + void _onListCache(WebViewController controller, BuildContext context) async { + await controller.evaluateJavascript('caches.keys()' + '.then((cacheKeys) => JSON.stringify({"cacheKeys" : cacheKeys, "localStorage" : localStorage}))' + '.then((caches) => Snackbar.postMessage(caches))'); + } + + void _onClearCache(WebViewController controller, BuildContext context) async { + await controller.clearCache(); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(const SnackBar( + content: Text("Cache cleared."), + )); + } + + void _onClearCookies( + WebViewController controller, BuildContext context) async { + final bool hadCookies = await WebView.platform.clearCookies(); + String message = 'There were cookies. Now, they are gone!'; + if (!hadCookies) { + message = 'There are no cookies.'; + } + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(SnackBar( + content: Text(message), + )); + } + + void _onNavigationDelegateExample( + WebViewController controller, BuildContext context) async { + final String contentBase64 = + base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); + await controller.loadUrl('data:text/html;base64,$contentBase64'); + } + + Widget _getCookieList(String cookies) { + if (cookies == null || cookies == '""') { + return Container(); + } + final List cookieList = cookies.split(';'); + final Iterable cookieWidgets = + cookieList.map((String cookie) => Text(cookie)); + return Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: cookieWidgets.toList(), + ); + } +} + +class _NavigationControls extends StatelessWidget { + const _NavigationControls(this._webViewControllerFuture) + : assert(_webViewControllerFuture != null); + + final Future _webViewControllerFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _webViewControllerFuture, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final bool webViewReady = + snapshot.connectionState == ConnectionState.done; + final WebViewController? controller = snapshot.data; + if (controller == null) return Container(); + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller.canGoBack()) { + await controller.goBack(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar(content: Text("No back history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller.canGoForward()) { + await controller.goForward(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar( + content: Text("No forward history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.replay), + onPressed: !webViewReady + ? null + : () { + controller.reload(); + }, + ), + ], + ); + }, + ); + } +} + +/// Callback type for handling messages sent from Javascript running in a web view. +typedef void JavascriptMessageHandler(JavascriptMessage message); diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart new file mode 100644 index 000000000000..d8178acd8096 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart @@ -0,0 +1,12 @@ +// Copyright 2013 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. + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart new file mode 100644 index 000000000000..c1ff8dc5a690 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart @@ -0,0 +1,19 @@ +// Copyright 2013 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. + +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({required this.url, required this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart b/packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart new file mode 100644 index 000000000000..33773f96cad8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart @@ -0,0 +1,617 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_android/webview_android.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'navigation_decision.dart'; +import 'navigation_request.dart'; + +/// Optional callback invoked when a web view is first created. [controller] is +/// the [WebViewController] for the created web view. +typedef void WebViewCreatedCallback(WebViewController controller); + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef FutureOr NavigationDelegate( + NavigationRequest navigation); + +/// Signature for when a [WebView] has started loading a page. +typedef void PageStartedCallback(String url); + +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + +/// Signature for when a [WebView] is loading a page. +typedef void PageLoadingCallback(int progress); + +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + +/// A web view widget for showing html content. +/// +/// The [WebView] widget wraps around the [AndroidWebView] or +/// [SurfaceAndroidWebView] classes and acts like a facade which makes it easier +/// to inject a [AndroidWebView] or [SurfaceAndroidWebView] control into the +/// widget tree. +/// +/// The [WebView] widget is controlled using the [WebViewController] which is +/// provided through the `onWebViewCreated` callback. +/// +/// In this example project it's main purpose is to facilitate integration +/// testing of the `webview_flutter_android` package. +class WebView extends StatefulWidget { + /// Creates a new web view. + /// + /// The web view can be controlled using a `WebViewController` that is passed to the + /// `onWebViewCreated` callback once the web view is created. + /// + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. + const WebView({ + Key? key, + this.onWebViewCreated, + this.initialUrl, + this.javascriptMode = JavascriptMode.disabled, + this.javascriptChannels, + this.navigationDelegate, + this.gestureRecognizers, + this.onPageStarted, + this.onPageFinished, + this.onProgress, + this.onWebResourceError, + this.debuggingEnabled = false, + this.gestureNavigationEnabled = false, + this.userAgent, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.allowsInlineMediaPlayback = false, + }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), + assert(allowsInlineMediaPlayback != null), + super(key: key); + + static WebViewPlatform _platform = AndroidWebView(); + + /// The WebView platform that's used by this WebView. + /// + /// The default value is [AndroidWebView]. + static WebViewPlatform get platform => _platform; + + /// Sets a custom [WebViewPlatform]. + /// + /// This property can be set to use a custom platform implementation for WebViews. + /// + /// Setting `platform` doesn't affect [WebView]s that were already created. + /// + /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. + static set platform(WebViewPlatform platform) { + _platform = platform; + } + + /// If not null invoked once the web view is created. + final WebViewCreatedCallback? onWebViewCreated; + + /// Which gestures should be consumed by the web view. + /// + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + /// The initial URL to load. + final String? initialUrl; + + /// Whether Javascript execution is enabled. + final JavascriptMode javascriptMode; + + /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. + /// + /// For each [JavascriptChannel] in the set, a channel object is made available for the + /// JavaScript code in a window property named [JavascriptChannel.name]. + /// The JavaScript code can then call `postMessage` on that object to send a message that will be + /// passed to [JavascriptChannel.onMessageReceived]. + /// + /// For example for the following JavascriptChannel: + /// + /// ```dart + /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); + /// ``` + /// + /// JavaScript code can call: + /// + /// ```javascript + /// Print.postMessage('Hello'); + /// ``` + /// + /// To asynchronously invoke the message handler which will print the message to standard output. + /// + /// Adding a new JavaScript channel only takes affect after the next page is loaded. + /// + /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple + /// channels in the list. + /// + /// A null value is equivalent to an empty set. + final Set? javascriptChannels; + + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate? navigationDelegate; + + /// Controls whether inline playback of HTML5 videos is allowed on iOS. + /// + /// This field is ignored on Android because Android allows it by default. + /// + /// By default `allowsInlineMediaPlayback` is false. + final bool allowsInlineMediaPlayback; + + /// Invoked when a page starts loading. + final PageStartedCallback? onPageStarted; + + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any Javascript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.evaluateJavascript] can assume this. + final PageFinishedCallback? onPageFinished; + + /// Invoked when a page is loading. + final PageLoadingCallback? onProgress; + + /// Invoked when a web resource has failed to load. + /// + /// This callback is only called for the main page. + final WebResourceErrorCallback? onWebResourceError; + + /// Controls whether WebView debugging is enabled. + /// + /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). + /// + /// WebView debugging is enabled by default in dev builds on iOS. + /// + /// To debug WebViews on iOS: + /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) + /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> + /// + /// By default `debuggingEnabled` is false. + final bool debuggingEnabled; + + /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. + /// + /// This only works on iOS. + /// + /// By default `gestureNavigationEnabled` is false. + final bool gestureNavigationEnabled; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String? userAgent; + + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + + @override + _WebViewState createState() => _WebViewState(); +} + +class _WebViewState extends State { + final Completer _controller = + Completer(); + late final JavascriptChannelRegistry _javascriptChannelRegistry; + late final _PlatformCallbacksHandler _platformCallbacksHandler; + + @override + void initState() { + super.initState(); + _platformCallbacksHandler = _PlatformCallbacksHandler(widget); + _javascriptChannelRegistry = + JavascriptChannelRegistry(widget.javascriptChannels); + } + + @override + void didUpdateWidget(WebView oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.future.then((WebViewController controller) { + controller.updateWidget(widget); + }); + } + + @override + Widget build(BuildContext context) { + return WebView.platform.build( + context: context, + onWebViewPlatformCreated: + (WebViewPlatformController? webViewPlatformController) { + WebViewController controller = WebViewController( + widget, + webViewPlatformController!, + _javascriptChannelRegistry, + ); + _controller.complete(controller); + + if (widget.onWebViewCreated != null) { + widget.onWebViewCreated!(controller); + } + }, + webViewPlatformCallbacksHandler: _platformCallbacksHandler, + creationParams: CreationParams( + initialUrl: widget.initialUrl, + webSettings: _webSettingsFromWidget(widget), + javascriptChannelNames: + _javascriptChannelRegistry.channels.keys.toSet(), + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, + userAgent: widget.userAgent, + ), + javascriptChannelRegistry: _javascriptChannelRegistry, + ); + } +} + +class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { + _PlatformCallbacksHandler(this._webView); + + final WebView _webView; + + @override + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) async { + if (url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $url'); + return false; + } + print('allowing navigation to $url'); + return true; + } + + @override + void onPageStarted(String url) { + if (_webView.onPageStarted != null) { + _webView.onPageStarted!(url); + } + } + + @override + void onPageFinished(String url) { + if (_webView.onPageFinished != null) { + _webView.onPageFinished!(url); + } + } + + @override + void onProgress(int progress) { + if (_webView.onProgress != null) { + _webView.onProgress!(progress); + } + } + + void onWebResourceError(WebResourceError error) { + if (_webView.onWebResourceError != null) { + _webView.onWebResourceError!(error); + } + } +} + +/// Controls a [WebView]. +/// +/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] +/// callback for a [WebView] widget. +class WebViewController { + /// Creates a [WebViewController] which can be used to control the provided + /// [WebView] widget. + WebViewController( + this._widget, + this._webViewPlatformController, + this._javascriptChannelRegistry, + ) : assert(_webViewPlatformController != null) { + _settings = _webSettingsFromWidget(_widget); + } + + final JavascriptChannelRegistry _javascriptChannelRegistry; + + final WebViewPlatformController _webViewPlatformController; + + late WebSettings _settings; + + WebView _widget; + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, { + Map? headers, + }) async { + assert(url != null); + _validateUrlString(url); + return _webViewPlatformController.loadUrl(url, headers); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If [WebView.initialUrl] was never specified, returns `null`. + /// Note that this operation is asynchronous, and it is possible that the + /// current URL changes again by the time this function returns (in other + /// words, by the time this future completes, the WebView may be displaying a + /// different URL). + Future currentUrl() { + return _webViewPlatformController.currentUrl(); + } + + /// Checks whether there's a back history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has + /// changed by the time the future completed. + Future canGoBack() { + return _webViewPlatformController.canGoBack(); + } + + /// Checks whether there's a forward history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has + /// changed by the time the future completed. + Future canGoForward() { + return _webViewPlatformController.canGoForward(); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + return _webViewPlatformController.goBack(); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + return _webViewPlatformController.goForward(); + } + + /// Reloads the current URL. + Future reload() { + return _webViewPlatformController.reload(); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + /// + /// Note: Calling this method also triggers a reload. + Future clearCache() async { + await _webViewPlatformController.clearCache(); + return reload(); + } + + /// Update the widget managed by the [WebViewController]. + Future updateWidget(WebView widget) async { + _widget = widget; + await _updateSettings(_webSettingsFromWidget(widget)); + await _updateJavascriptChannels( + _javascriptChannelRegistry.channels.values.toSet()); + } + + Future _updateSettings(WebSettings newSettings) { + final WebSettings update = + _clearUnchangedWebSettings(_settings, newSettings); + _settings = newSettings; + return _webViewPlatformController.updateSettings(update); + } + + Future _updateJavascriptChannels( + Set? newChannels) async { + final Set currentChannels = + _javascriptChannelRegistry.channels.keys.toSet(); + final Set newChannelNames = _extractChannelNames(newChannels); + final Set channelsToAdd = + newChannelNames.difference(currentChannels); + final Set channelsToRemove = + currentChannels.difference(newChannelNames); + if (channelsToRemove.isNotEmpty) { + await _webViewPlatformController + .removeJavascriptChannels(channelsToRemove); + } + if (channelsToAdd.isNotEmpty) { + await _webViewPlatformController.addJavascriptChannels(channelsToAdd); + } + _javascriptChannelRegistry.updateJavascriptChannelsFromSet(newChannels); + } + + /// Evaluates a JavaScript expression in the context of the current page. + /// + /// On Android returns the evaluation result as a JSON formatted string. + /// + /// On iOS depending on the value type the return value would be one of: + /// + /// - For primitive JavaScript types: the value string formatted (e.g JavaScript 100 returns '100'). + /// - For JavaScript arrays of supported types: a string formatted NSArray(e.g '(1,2,3), note that the string for NSArray is formatted and might contain newlines and extra spaces.'). + /// - Other non-primitive types are not supported on iOS and will complete the Future with an error. + /// + /// The Future completes with an error if a JavaScript error occurred, or on iOS, if the type of the + /// evaluated expression is not supported as described above. + /// + /// When evaluating Javascript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the Javascript + /// embedded in the main frame HTML has been loaded. + Future evaluateJavascript(String javascriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); + } + // TODO(amirh): remove this on when the invokeMethod update makes it to stable Flutter. + // https://github.com/flutter/flutter/issues/26431 + // ignore: strong_mode_implicit_dynamic_method + return _webViewPlatformController.evaluateJavascript(javascriptString); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + return _webViewPlatformController.getTitle(); + } + + /// Sets the WebView's content scroll position. + /// + /// The parameters `x` and `y` specify the scroll position in WebView pixels. + Future scrollTo(int x, int y) { + return _webViewPlatformController.scrollTo(x, y); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. + Future scrollBy(int x, int y) { + return _webViewPlatformController.scrollBy(x, y); + } + + /// Return the horizontal scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + return _webViewPlatformController.getScrollX(); + } + + /// Return the vertical scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + return _webViewPlatformController.getScrollY(); + } + + // This method assumes that no fields in `currentValue` are null. + WebSettings _clearUnchangedWebSettings( + WebSettings currentValue, WebSettings newValue) { + assert(currentValue.javascriptMode != null); + assert(currentValue.hasNavigationDelegate != null); + assert(currentValue.hasProgressTracking != null); + assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent != null); + assert(newValue.javascriptMode != null); + assert(newValue.hasNavigationDelegate != null); + assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent != null); + + JavascriptMode? javascriptMode; + bool? hasNavigationDelegate; + bool? hasProgressTracking; + bool? debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); + if (currentValue.javascriptMode != newValue.javascriptMode) { + javascriptMode = newValue.javascriptMode; + } + if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { + hasNavigationDelegate = newValue.hasNavigationDelegate; + } + if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { + hasProgressTracking = newValue.hasProgressTracking; + } + if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { + debuggingEnabled = newValue.debuggingEnabled; + } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } + + return WebSettings( + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + hasProgressTracking: hasProgressTracking, + debuggingEnabled: debuggingEnabled, + userAgent: userAgent, + ); + } + + Set _extractChannelNames(Set? channels) { + final Set channelNames = channels == null + ? {} + : channels.map((JavascriptChannel channel) => channel.name).toSet(); + return channelNames; + } + + // Throws an ArgumentError if `url` is not a valid URL string. + void _validateUrlString(String url) { + try { + final Uri uri = Uri.parse(url); + if (uri.scheme.isEmpty) { + throw ArgumentError('Missing scheme in URL string: "$url"'); + } + } on FormatException catch (e) { + throw ArgumentError(e); + } + } +} + +WebSettings _webSettingsFromWidget(WebView widget) { + return WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + hasProgressTracking: widget.onProgress != null, + debuggingEnabled: widget.debuggingEnabled, + gestureNavigationEnabled: widget.gestureNavigationEnabled, + allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, + userAgent: WebSetting.of(widget.userAgent), + ); +} diff --git a/packages/webview_flutter/webview_flutter_android/example/pubspec.yaml b/packages/webview_flutter/webview_flutter_android/example/pubspec.yaml new file mode 100644 index 000000000000..1e065a6a5b0b --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: webview_flutter_android_example +description: Demonstrates how to use the webview_flutter_android plugin. +publish_to: none + +environment: + sdk: ">=2.14.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + webview_flutter_android: + # When depending on this package from a real application you should use: + # webview_flutter: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + espresso: ^0.1.0+2 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter + pedantic: ^1.10.0 + +flutter: + uses-material-design: true + assets: + - assets/sample_audio.ogg + - assets/sample_video.mp4 diff --git a/packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart b/packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/webview_flutter/webview_flutter_android/lib/webview_android.dart b/packages/webview_flutter/webview_flutter_android/lib/webview_android.dart new file mode 100644 index 000000000000..a48e457d55ad --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/webview_android.dart @@ -0,0 +1,62 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +/// Builds an Android webview. +/// +/// This is used as the default implementation for [WebView.platform] on Android. It uses +/// an [AndroidView] to embed the webview in the widget hierarchy, and uses a method channel to +/// communicate with the platform code. +class AndroidWebView implements WebViewPlatform { + @override + Widget build({ + required BuildContext context, + required CreationParams creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }) { + assert(webViewPlatformCallbacksHandler != null); + return GestureDetector( + // We prevent text selection by intercepting the long press event. + // This is a temporary stop gap due to issues with text selection on Android: + // https://github.com/flutter/flutter/issues/24585 - the text selection + // dialog is not responding to touch events. + // https://github.com/flutter/flutter/issues/24584 - the text selection + // handles are not showing. + // TODO(amirh): remove this when the issues above are fixed. + onLongPress: () {}, + excludeFromSemantics: true, + child: AndroidView( + viewType: 'plugins.flutter.io/webview', + onPlatformViewCreated: (int id) { + if (onWebViewPlatformCreated == null) { + return; + } + onWebViewPlatformCreated(MethodChannelWebViewPlatform( + id, + webViewPlatformCallbacksHandler, + javascriptChannelRegistry, + )); + }, + gestureRecognizers: gestureRecognizers, + layoutDirection: Directionality.maybeOf(context) ?? TextDirection.rtl, + creationParams: + MethodChannelWebViewPlatform.creationParamsToMap(creationParams), + creationParamsCodec: const StandardMessageCodec(), + ), + ); + } + + @override + Future clearCookies() => MethodChannelWebViewPlatform.clearCookies(); +} diff --git a/packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart b/packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart new file mode 100644 index 000000000000..6beae105e2e5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart @@ -0,0 +1,78 @@ +// Copyright 2013 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 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'webview_android.dart'; + +/// Android [WebViewPlatform] that uses [AndroidViewSurface] to build the [WebView] widget. +/// +/// To use this, set [WebView.platform] to an instance of this class. +/// +/// This implementation uses hybrid composition to render the [WebView] on +/// Android. It solves multiple issues related to accessibility and interaction +/// with the [WebView] at the cost of some performance on Android versions below +/// 10. See https://github.com/flutter/flutter/wiki/Hybrid-Composition for more +/// information. +class SurfaceAndroidWebView extends AndroidWebView { + @override + Widget build({ + required BuildContext context, + required CreationParams creationParams, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + }) { + assert(webViewPlatformCallbacksHandler != null); + return PlatformViewLink( + viewType: 'plugins.flutter.io/webview', + surfaceFactory: ( + BuildContext context, + PlatformViewController controller, + ) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: gestureRecognizers ?? + const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (PlatformViewCreationParams params) { + return PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: 'plugins.flutter.io/webview', + // WebView content is not affected by the Android view's layout direction, + // we explicitly set it here so that the widget doesn't require an ambient + // directionality. + layoutDirection: TextDirection.rtl, + creationParams: MethodChannelWebViewPlatform.creationParamsToMap( + creationParams, + usesHybridComposition: true, + ), + creationParamsCodec: const StandardMessageCodec(), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener((int id) { + if (onWebViewPlatformCreated == null) { + return; + } + onWebViewPlatformCreated( + MethodChannelWebViewPlatform( + id, + webViewPlatformCallbacksHandler, + javascriptChannelRegistry, + ), + ); + }) + ..create(); + }, + ); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/pubspec.yaml b/packages/webview_flutter/webview_flutter_android/pubspec.yaml new file mode 100644 index 000000000000..f7db4c6fb63a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/pubspec.yaml @@ -0,0 +1,31 @@ +name: webview_flutter_android +description: A Flutter plugin that provides a WebView widget on Android. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_android +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 +version: 2.0.13 + +environment: + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" + +flutter: + plugin: + implements: webview_flutter + platforms: + android: + package: io.flutter.plugins.webviewflutter + pluginClass: WebViewFlutterPlugin + +dependencies: + flutter: + sdk: flutter + + webview_flutter_platform_interface: ^1.0.0 + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + pedantic: ^1.10.0 + diff --git a/script/configs/exclude_all_plugins_app.yaml b/script/configs/exclude_all_plugins_app.yaml index 8dd0fde5ef5f..f6246aae2c86 100644 --- a/script/configs/exclude_all_plugins_app.yaml +++ b/script/configs/exclude_all_plugins_app.yaml @@ -8,3 +8,9 @@ # This is a permament entry, as it should never be a direct app dependency. - plugin_platform_interface +# TODO(mvanbeusekom): Remove the exclusion of the webview_flutter_android and +# webview_flutter_wkwebview packages once the native +# implementation is removed from the webview_flutter +# package (see https://github.com/flutter/flutter/issues/86286). +- webview_flutter_android +- webview_flutter_wkwebview From c05887f7049a9405dfe0156ecc8762ef4d6acca8 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 21 Sep 2021 08:40:24 +0200 Subject: [PATCH 308/364] [webview_flutter] Extract WKWebView implementation into a separate package (#4345) * Setup webview_flutter_wkwebview package. Creates a new `webview_flutter_wkwebview` directory and adds the following meta-data files: - `AUTHORS`: copied from the `webview_flutter` package and added my name; - `CHANGELOG.md`: new file adding description for release 0.0.1; - `LICENSE`: copied from the `webview_flutter` package; - `README.md`: new file adding the standard platform implementation description; - `pubspec.yaml`: new file adding package meta-data for the `webview_flutter_wkwebview` package. * Direct copy of "iOS" folder. A one to one copy of the `webview_flutter/ios` folder to `webview_flutter_wkwebview/` using the following command: ``` cp -R ./webview_flutter/ios ./webview_flutter_wkwebview/ ``` * Rename .podspec file to match package name. For the Cocaopod package to be registered correctly the .podspec file name needs to match the name of the Flutter package. * Direct copy of WKWebView specific .dart files. Copied the WKWebView specific .dart files over from the `./webview_flutter` package. * Modify .dart code to work with new platform_interface. Make sure the `CupertinoWebView` widget extends the `WebViewPlatform` class from the `webview_flutter_platform_interface` package correctly by accepting an instance of the `JavascriptChannelRegistry` class. * Direct copy of the `webview_flutter/example` app. This commit makes a direct copy of the `webview_flutter/example` app to the `webview_flutter_wkwebview` package. After the copy the `example/android` folder is removed as it doesn't serve a purpose in the WKWebView specific package. Commands run where: ``` cp -R ./webview_flutter/example ./webview_flutter_wkwebview/ rm -rf ./webview_flutter_wkwebview/example/ios ``` * Update example to WKWebView specific implementation. This commit updates the example App so it directly implements the WKWebView specific implementation of the webview_flutter_platform_interface. * Update integration tests. Updated the existing integration tests (copied from webview_flutter package) so they work correctly with the implementation of the webview_flutter_wkwebview package. Co-authored-by: BeMacized * Fix iOS UI tests. This commit resolves failing UI tests and ensures the `Publishable` task is green. * Point to existing documentation URL Update the documentation URL in the `ios/webview_flutter_wkwebview.podspec` file to point to a valid location. The `https://pub.dev/packages/webview_flutter_wkwebview` package doesn't exists until this PR is published. However the `pod lib lint` step in CI is failing if the URL doesn't exist yet. * Split helper classes from main example widget. Move the `WebView` and related `WebViewController` classes from the main.dart into a separate web_view.dart file. * Updated version numbers as suggested in review. Updated the version of the plugin to the version of webview_flutter package (2.0.13). Also updated the Dart and Flutter versions to respectively 2.14.0 and 2.5.0. Co-authored-by: BeMacized --- .../webview_flutter_wkwebview/AUTHORS | 67 + .../webview_flutter_wkwebview/CHANGELOG.md | 3 + .../webview_flutter_wkwebview/LICENSE | 25 + .../webview_flutter_wkwebview/README.md | 11 + .../example/.metadata | 8 + .../example/README.md | 8 + .../example/assets/sample_audio.ogg | Bin 0 -> 36870 bytes .../example/assets/sample_video.mp4 | Bin 0 -> 1053651 bytes .../webview_flutter_test.dart | 1213 +++++++++++++++++ .../ios/Flutter/AppFrameworkInfo.plist | 26 + .../example/ios/Flutter/Debug.xcconfig | 2 + .../example/ios/Flutter/Release.xcconfig | 2 + .../example/ios/Podfile | 45 + .../ios/Runner.xcodeproj/project.pbxproj | 722 ++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/Runner.xcscheme | 107 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../example/ios/Runner/AppDelegate.h | 10 + .../example/ios/Runner/AppDelegate.m | 17 + .../AppIcon.appiconset/Contents.json | 122 ++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 11112 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 564 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1588 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 1025 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 1716 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 1920 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1895 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 3831 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 1888 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 3294 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 3612 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + .../example/ios/Runner/Info.plist | 45 + .../example/ios/Runner/main.m | 13 + .../FLTWKNavigationDelegateTests.m | 41 + .../example/ios/RunnerTests/FLTWebViewTests.m | 91 ++ .../example/ios/RunnerTests/Info.plist | 22 + .../ios/RunnerUITests/FLTWebViewUITests.m | 101 ++ .../example/ios/RunnerUITests/Info.plist | 22 + .../example/lib/main.dart | 344 +++++ .../example/lib/navigation_decision.dart | 12 + .../example/lib/navigation_request.dart | 19 + .../example/lib/web_view.dart | 592 ++++++++ .../example/pubspec.yaml | 33 + .../example/test_driver/integration_test.dart | 7 + .../ios/Assets/.gitkeep | 0 .../ios/Classes/FLTCookieManager.h | 14 + .../ios/Classes/FLTCookieManager.m | 49 + .../ios/Classes/FLTWKNavigationDelegate.h | 21 + .../ios/Classes/FLTWKNavigationDelegate.m | 116 ++ .../ios/Classes/FLTWKProgressionDelegate.h | 19 + .../ios/Classes/FLTWKProgressionDelegate.m | 41 + .../ios/Classes/FLTWebViewFlutterPlugin.h | 8 + .../ios/Classes/FLTWebViewFlutterPlugin.m | 18 + .../ios/Classes/FlutterWebView.h | 32 + .../ios/Classes/FlutterWebView.m | 491 +++++++ .../ios/Classes/JavaScriptChannelHandler.h | 17 + .../ios/Classes/JavaScriptChannelHandler.m | 36 + .../ios/webview_flutter_wkwebview.podspec | 23 + .../lib/src/webview_cupertino.dart | 49 + .../lib/webview_flutter_wkwebview.dart | 5 + .../webview_flutter_wkwebview/pubspec.yaml | 29 + 73 files changed, 4814 insertions(+) create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/AUTHORS create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/LICENSE create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/README.md create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/.metadata create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/README.md create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_audio.ogg create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_video.mp4 create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Assets/.gitkeep create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart create mode 100644 packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml diff --git a/packages/webview_flutter/webview_flutter_wkwebview/AUTHORS b/packages/webview_flutter/webview_flutter_wkwebview/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md b/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md new file mode 100644 index 000000000000..1a85bc8a53e5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md @@ -0,0 +1,3 @@ +## 2.0.13 + +* Extract WKWebView implementation from `webview_flutter`. diff --git a/packages/webview_flutter/webview_flutter_wkwebview/LICENSE b/packages/webview_flutter/webview_flutter_wkwebview/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webview_flutter/webview_flutter_wkwebview/README.md b/packages/webview_flutter/webview_flutter_wkwebview/README.md new file mode 100644 index 000000000000..2e3a87b7f310 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/README.md @@ -0,0 +1,11 @@ +# webview\_flutter\_wkwebview + +The Apple WKWebView implementation of [`webview_flutter`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `webview_flutter` +normally. This package will be automatically included in your app when you do. + +[1]: https://pub.dev/packages/webview_flutter +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/.metadata b/packages/webview_flutter/webview_flutter_wkwebview/example/.metadata new file mode 100644 index 000000000000..da83b1ada1bd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/.metadata @@ -0,0 +1,8 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 1e5cb2d87f8542f9fbbd0f22d528823274be0acb + channel: master diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/README.md b/packages/webview_flutter/webview_flutter_wkwebview/example/README.md new file mode 100644 index 000000000000..850ee74397a9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/README.md @@ -0,0 +1,8 @@ +# webview_flutter_example + +Demonstrates how to use the webview_flutter plugin. + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](https://flutter.dev/). diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_audio.ogg b/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_audio.ogg new file mode 100644 index 0000000000000000000000000000000000000000..27e17104277b38ae70d3936f5afb4ea32477ec22 GIT binary patch literal 36870 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 XfG+?r+!9a#U;+;`01|XOAOHdYynO-T literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_video.mp4 b/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..a203d0cdf13ea5106cdb8c98b52301f0e3719bac GIT binary patch literal 1053651 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%0A0_A2BB?T)i~ z4N2W5nUMXhn#m5ZjZ%8r@Zo^*hAJ$sW*4*wd7e z-^TSy`GRU+6j(e{#3C)$=TSoCvXhQ~i^5G`ofJ5H1Z3HM)UsT1Coa+z;Y7uEc0L$+P?b`&$h#6;Yn%gIcBF+k7a&xe@o$Bh~szTL@SG zT)$gE=cK;ka@n3^lyUynKCW8B-PT8qelkZD(p2E)OHfV`|IC)anf? zXHgh~dn(MW&9GyMCugeU#Cy||Bqd}VgD9hSYV51>!eh1?d3T5-#)3}6ZJUinQXnP^ z;*${1{1F^$7KNfuI$E)L=5yDpAh4BNO+c5$1xW7x#OG3owTv)i_)h!cjohtgw6V

    Jbq-814NPd$TYkZAM*DuEbPgkd!5Mv&pQs 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%0A$ zgTwo-5s;^2I_aPu>3wyo4WS;0+O}v==<;Wr)Ey*%WJJ2o&;t|7eT?_4Z#d4FzBA?j z{9}~z|2ZbVChG7R#$JomkkzNv{XGv~oS;o%29FfwP23H>$S#fu1j~?-OKVyn8DPIgb#$%gd(Y47q$Ui?X{OC&#cIyS4|VlT8`gm% zr&6UFQTw9G4c*G*WDi}ZWQYwG&e*SuRMRX|G|3o6=ev>(%g8BS$ z^27-8AQz#&J~vm~wI~0&9wsC22E;qdDl1VPzpFaCFg)4iUaJ{=<3FAT^*B$hVFz7K zL)kuRKQ}B1%eMzc;I{eQuR<4B>7rebKcaCy1Ax&z>~OB}KXWe9ft2K|?%$^7EY-|9 z-T_)4KUK(U;9w)2Xd`zcg;bw`n|Y68T%X5POj*h2$G!B~2aqJpr9pb-i7v|&s3`@U zB6d=N81wGZf4+Tt>GOWqHzH`iKjWNXFxScz=M($&y19aiJ&BA&LP;psS3$~O89Ljm z!5^m^UpYQw-JYA9Uo_^=4|)AH3l@`6;`dRU1>?V6_JUSv8PtlP8#Wal;)E%)%K?hn z_p3xa2-o#wxT9y=#$#*j^e#l%ib_1zD@E5@?Y4=xa?wpv@n+W9!bn*|z*;Q8YRzLA z53%dK3mha6tSy=gJ0Ujjm(1?B0TOu38dorvJ_7#_extxa+ zoP>>#XgupYK)0#`lE?*+YBJK;JT+xn$c_=Td@2$h8TFLj8 z?JdpgIQK$tG&N2kn`-V5PJ_DVeZ{*@Wyw2b+1rPshoX4a5i3d&I92u_-*gy3uKVv%t6Wsy_*25$<4al=+KSPT_M~LyF>g}T~ z)tY~v;426&1CHmlCZ6t?!5sbtybSW7jOtPSi=BY`Kr|Cx9-NBb5ZMep%6qVX@pE}? zjy=!0tA(#l+-jcVa}vx+`Stm;9o~&a+xse$SvftN@UUa&G8^e!ncH*J*RZM@S!q&~V%p z)qkiM&IvEJ%qJsL6d;2@VtVEgaus&q0}+D4kXtF|BZH)~=q@UT)XFAJ=oN|WMB1*U z1e-#^TQ+pE`V{H1kGT8PwcWPFbs+j^K5VZ)kLHB3C2@V&{1Cmzy;69@JnxkA9kX8H zP@JV!;!M9se&!Q+{2*7{wdU4D1lDmoA)X;LsXsNn9%0gOK$el}?N9;r#74k4cHi5`*cYd)l+^*-e zXgoe97vE@>9yM3Gtpn%TY-j6afG_-1UtPnXvtSS)mMeGbA@g{xbv>`0pPVb^4i+km z0X?<=VvZU8d)U_vTS8kiZ@Yad%=u{8{Xy+ADnGp(h?|pA6PU}CtMZZI9@i*$c69uc zqVzBnUsK<~y`Ky_gTArXA}T3tGY__P(Dz}=!nja~1p_NM!HE`3CMcJ3xfQ6O^TXnp zq*3aM8Wi=XE*v_HzW`KTs=p;%r=-nx1Egaz@K0Y&v<*X|k_NHA(1L#iMnO=qK6`kF zD>;(iPK6X7mMI2dLF z1g-M6cD1#uorsJemUnhag-A?BFz4@3l!Qf%WemAPI~Jbz{COkN3F3YVP_GdAwwXe! zk$QXqy{ycT{F-rG`+!qc?Fk~dMrAkCySA-^x*X?<)AZ7%>AU!(E6v$uBHk@eeC09V`-*LQj}?W)A2)-F8dNBhaz9|ivV2Mk{dmpWG3&mBIA(&|dLP2c)+eD=p z@aK?xB~;?wig)g7cW$4_jBipx=*>kaao_;VVx4d;vnSr28ne-_*MwSH6SjH355IZp z>n7PWd~$+2w#lHa2Zi|`AaVx%P>WQYm%K$_?ua~yH(_y0c&a77YTAgrcIyBdVuxMU zui8j%;!) z#*0=Ah0WQk@AaEc&c{*PqIThnoX+h!;X`f_kEvz>51%mo0`IHFyxJ-lwzaLmZMXCD zw!984h;-^7RMo6!BeqhQLQIvIYtdCXiMl=8pa40SN6GU$q{29gf~K2hTql_NjKR%S zo467{It@S93&5g09z3jtKfWYdH9!!5T@ z?x|3#tvWn#oQ+{Tt}rW#0h?*o-SlPlpqtZ0a!!1-qwI~F$19k3heIK+`%>Kis0QzNr{1z%wJw4VL*Iup^qtR@b#=mJx$qw}| z@@FWO+KX1a6i!dRQKVJtZ|C}Qy~)|22D@FST`wv^uJ!MnsyEP7uYe*f9X$XWug2|A zn;WuYZG_z1Dz?oOkyTc33+qtl+@5go5(tKHgCCaU*`RQli!mo9#BcL1n5O0d?26hG zH_7>L;=QFU_2@&#obbItJp%8&XVu@ixPN0p9LdOavyCWq%@>NVI4M%n#>u-crR+vw zCJK5W`W+II=wi{}`P$U!QHdd9e~T{-a7OTaj*iJ|dleE0#iDYn&V-NTRk^Kn&aUkTYplmN){C z>vF@&vTn|~hY;@xWpx$PR`*M#@A5>|F0R!Uqb>imt!TR3u?&<$AR#WNhC5G>EVR?%Ymr9jZ4gD}RTImj1Eo zH>g{F&uth`FCtbY9{a6#58gH&)j*7Rhc%Wpm>ON|T|WHXAV@AP5;P*C;Xu`0Tq#PQ z*#Ysj)Go-Y@)+l!U}fBEOWVj371800?ApCVlR&IlO3E7o%kb@`Ex|jzR&6?}cT*A@ z*5cnYB0=#aZK#8)&=2;_eLiZ9v!8FGgwPczn)Ocq5||Dzg!WEQxo*P8gU|rO!9Zz{ zJHNPgqeU;v0@fNCsR*F2KJu3^P|r^G?gPP*RtBbY<~NYR2@_IXyU7ou(J8=Z#p3Ai zCucFhb$6r#FqNf?+rE&Ym{MVWqxdVv3h=K>6uXs}M5o^Fv(>Akfhk9`Izi z>uOjPDsu^e?@n7#{{@x7;CA4f(V->Ly5QFo0ZgL!8 zKb#3|koRtgS#LyrV(V|S6TK#xs$w=74lH3=a+OupR>3!11Lv7ZfU-A0mdlpL;M@j7 zgCt{MzKoEfbbsJGi-CvEg125uym43--k;LB!B7T1=&1^$CXcNW_Ge2sCgxq-53m+y zVngiF4k5Z>SgGW$b0Gt`VzieOO?Fg@duk9fr(vYfowd?v?Kyv!dBPO+>1{^p2v03i zirBGquOxU@^bzjN;);y2AyvlBv}8w&q(a(3WAcG0NH$4kP(+TiC4z0$8(ZVzYW1uR z8{@-jjCRlt>@U*}bo<4_^_9vY>)86l&Uekub*plSM7}V_0p^ zhP6XE&2zi(J4$RCD16Z#pxx-D1sDQt79pJD%X=d|a7<*>tTT<2RKi4OR;bQ7F3vA7 zQJ$m{b{eyYq#2KbK7ADHsm%!Bu;#(#4}dcKfCi5vn)Py}@sU%J)~2qTm^Qy!y>Xzd zMRP+Fv9C3nv?9!NSK+S*1o)QC$u{W;94m&64-8IqR$p+cK;!`cvcoQ6u{twAyq|IMiPu5Ul|!ECh|PG6KN8m3tjzoYyZ>nd}>1 zdO+twUu)%1F|>OumGdrj2lCT=q<*)2G6r_>)Bc{wjs`+WlX!R;xwrWyo-x~=I$0Q` z9YHFOsnj`23j%+L{^84_y!8qaM1b1XiZ3 z7+Dp7GJX=jTF0em0j!{B)v$|E3+m~b56C$Yu(QU1j=%q8m?;0#Yq)2`Cs66)(`1-k z%XF_(mze*#B61w2o%^&X2|l{|t`?m)o*isSl%4y-w?&-TQnv&1q^fMH91Q+ELizRj zhqD2N=^R)WBd<4C;p}427#@#ihqbGB94pdG-XU0uo?FlP8Fv7&pOD5$+{T!-@-jCd z!Uh3l%4kaJU4=LdfgL7NR1LXFS_zlNVCn?p0~5aBJcs15VF6({%6M)W>MVN6bkw+? zBP6y*q8zb2zrbQ+wc5!r#*yWU*0cxYNt18ws2SWg!>qSpl8xrLxp20K;7Qzx=Cldr zL>WpiHC6%^^x==FP8}+CC9R=|O#D8kkp%TmV-NB&AgQcTmfs1B;qcpoz|Qh5 zsJtQI^*RaGUhi?8avwJb{4$9{E$Qf&D{eReo5;zbEj-Z)QUNt9Z46wsCWA{!z$qS4 z-r+Kt9pP;-Wt!DWV2X^hyD%D#QWM}q(W}DC_=)T=D3S$0fo8tu9R{IFn&f0BNAkUu^N=gTiXFp#2G5Tu5w*63OHTQf0~S z8+d~7T(?;QbS>Q}{tT9u=~&n=8)|MWS@MARkoPwTPgDiEMQTBb9>h2pxk0?+`3~a7 z^CNDE+WG~(N5;%lV2Vvq?4{qP@26L1LoPm34Lf~K@nh=VA*7RouJ#+gJYBBh1&dL{ z8ks6YhOs8lu05oyD%mATJgn!Yl@KlghC&2fw%uQT_*Zd`r=qt(9X@Kk6lmyi#NSD^q3- z0}iQYO9k{)_yC#?v_&k)HEh7ALs}R(;*FJgP1N$QYz)h`uxe<0Z7|Q5W%GOPL@4_H zsm_nt*%%)a|FeP)vx9l?a3{0FrGA9c#V2@=8{RP2lnX$2yb$C-C;I8kNZ=m;lAE%o zc~+TpA$gZ^#VDnP<+(K0tYNI9;euQK4XW59{g(szfEPo)0b9hEzq}2}cu+%MW6@wF z(Y8)<=0&aPx`>gE(*laKmapaIiQp;Ut7>Hd$Xl&_;%0x{T{pzh%0m6;qGl?u8(%8h z(Kw%tq^13p!C^6^Cb(w+N}UZ_w@6|L$Pe+wKB1@c&~r&A#*6pk?QRJX>-E6dkj)2| zHhteq0pf>TW&=9|t*6O(iE|woaW{=nUx`IMmOc>B*HOZpG%6^f?wCa;4FTSFcWhG8 z7TLd(PHr@%r=K^tOl8Pj>y66lX%-vc*{$bn^yAwv$b65_3Q=j&4vL@-D`&rBKx5>!ZlzwPhuAu0sOATCdw5)r&?fWXW1X4>UR z@5J--`{DlE^X1)bUxG#3h$}&>t*CS?o7e$gbGiwW;g~(a|x6z3~>$EOo$Jv`<+n(ABFPUa0>hnwgm$QjB-$U89=V%a;rtIMe$B6n$((^#Mh1h zU)YwE0q6(~k=dHFyVoeiFJ+1g)~7((* zy}xbmo$ficCYr6!2Wii_Gh2AR-d`S`<)v1hjz?rTk~|{OAIv{lb^}(=Davf}i&>uz zpDNZMPv?_d4yISFIsuWgUq};M(c7i%qUqJasX?#ah9Q@p4b%^?t{FmGd!2k|L?S1~ z>czbl&JriD;2-DD`deK#l}4MJ>`{(}^A2Z7pQuWZl=%2Z(QhdYo+7Z**23X0-#+ht z9>)A>^rD=?7QFE+Uj*WW2NlgfH_IQNMkBQ_d=xx8cxYFT zmS620)oDJ!u8YrCSzaq^d}DqAB}jApIA?I44J+061A(cnkXFNsD+mAL-Vk$%TAK)_ss{i2vZOb zHMXvC=Wv0vWd=B^%3MdMH*Jr_qlp&Rdv*atB4sVe<{+~opHBsEMdMu_D*;o-g~m~X z@MKDK6OI4h_W)9cx6(wy1@8CDQ>Xtg#1cOOn&w!7soNsGTxCQ#DJ<2aJg)vnZ1lVJ z&S#TldcMn<_?D-uH~MXIgBL~WlSi|4K4#0b(MLjh*7veMi_>Q%M7Tp-$;RbB<{Sji zUgQ_PJOs@?u%%VeW5hjjD)i86wLV_1mBC7PIu3GMf9~P;sssJ_ZOX(b;8jB}7pqw(tFgn=A8)JOZ#6z|Gc`KeD~bzln0)46r6LHP1z{oE zSl>OnTKaXWqy&219*O5p!&$7Al)S7w#f5*o&8n;t6m$p&-iF(S!C(6n>wp$P!YiQs zLYuA&;X@7b)X7PqAp!yQ=i=x|Awhxq{l?@i5aEjkE^OXi4>lz4j@`Otzp~G1X-_r# zTvBSxe_YOe4t{buCO^NgpP)*`qHJ?YD$PKlJwr`(cc3ou=D_3UZc+}ad$(1_i^|u0 zn?IiY0-BP)yHi*v!qG*gRwJx-USOp!*sAO zs=xx_-dfIk;{)Enmw9o0@Ko}9G` zltc5)6U^KNKkyMF+dxBLRZ2j5@T7BqYEJU_sciF_oDDE}z#-WlIsook9VsH?N@AIs?*M`!=AK zykUx~D&xvp15E($uDbRZk3qR=`Y5IJNSHXIF9AtB`>CSlLwv1`pWe}oR}j7!K+1Gc zR^6ggDT$Vj$X-=$tP+}tWYwBJLKVdWNhrb{GPb9%PwHm)KL^W(u1n=Vx1l4M)Bty$)VT9uUhE7kjA@SGE3C9WsHq)u`>zOn&z z)@`Mt`nYsJF6 zn`hlttTIJd*sLv2(y8Hi&h%>Dx9muUeJK%p0#e-8(P=ZeK{d+q5$0m@D!MWP>F?Z6 z87yW44q;)Xlr1$Kd#62u=UMvL4PbqbbakaCq5sAuk|CJ9Q-fie5Kk5B*RLd4-9UG0 z$)(cMr_%q5%_N=)z48^gQqRkkuxT&H=8qEmz_;cgOW3p6T1Z_o%f?_~-rP`ozE=Bq z#*C2B(xnGpGo3PbpmbTtLcyKp=cpKRhpICy>5(jA8O?Ou?KP^z$US1I4ssjoLaJ8+ zb~nz3Uwsu?s!FArH=WtA9|I&-C_c?&PzZ7A+Q9<>@Ph;50}unFuv;l$AR2cpxp!9H zWBS2Xc}uX?QrTm|x04fp5_KEf(qZvp_A_Y^dfjlL3CXm8OBo&-q@836b*}$ zQ0|oIHeZqsTMF--9|z6!WXyH4J2Y)}&uF=&+fx?Tn~FrU2)N|g1b=g9dWrR%`&czC zr5~UED%(#Vza*9ph6MzhXwJ6F->u}cnOr2g)4<9#z5}a zv=_Y{;ee6pasvwqlLsmT+UF)pD=*~|f1+bxvCpaS(Z#>mpDbh{CH z8V8RAZ4J{glBJ1Lna4_3?v6*p)L1LIfY}I1XvS;-WP)5j>@Ulhzar8f~G7#|P5u52 z%HN-TmZQg5!d=fg<%JZAr;wwbEXUSkTcoG`kE8s63xq)UO?Mm3t*r@5WdgMXG&a!6 zmfDI#6d(rl;kg&-C#Z!lBC8TtiaB`}l9BKv=@X1R4LhyzkbT}-cpR2g&$mz!sS`BQ z`12s^A(Q=3^DU(wR1}2pvndp)B{%s?@Y%r6dan`{`f*vP5#);v8PJ1DjcA$wpG5%x zWhIa?@?T{%1ofs5l3NAFv^1RKK4BbadLH<-vtcM3mh+^){llbMJ+i4XMW-7D=sD(F z!CE$LeSydJTK-AX()yzd9Y`d`E-g+%S|lb2T_je-WtIY8r$CTe)ilU=9r9nBQXE{m zmbqK;KoV>~P;!P8IjD;$%RsArJj7l=1%nB>X3KIW{Tj?)s&&z5r3x?d?FH^gtj?%u zlyZO$j>AvGLzxX0Ny-q00^iFlW&aTCOdVXfNN7seD7O=}Xy3o(m8Xk7eK}3`eu0MLZ z^t0}vh{p$$&rz?G`4TDvUDY3uVizp?_*-0Ej|xw61Jkersis0?&g}!$q7t4B{2W&+%n=kKljFmpXQsIF;Xn0$BD77(!9f<#U>2k zH+M?wq1-;0M@K#yAiE6z(iL)e>@$Yahq~PM!vQTJ3VCCv93!T8h8p1W>*{IO!U)Xi z`SwAW)NmW*E7kcxbARW)5LH3A$%IC!f7MB|VZzsBm5l^*KjUr{^HoWh(M$ER0VYdS z($t)(m3`EUBxlV19igJz_#j-0iSvEXyU%c&0UlDk!}-1ZJFk>x=nOTrdlMzK1Wp(= zg93D9!`1V(681wUa`PCgCJSH2C+9au{-aisp5<7xVrSCD4pSG6=(RzPmolk0`r_ee zNTU;mkf&1vQs#75xT(n?bc8fSm|M!i7iHUa138P;3?%D2`OBSP2#Tp}AP_4RooMhS z0^GbIK30}+;j&n(*;qN6=r3`(sHm#Xo!T(h4IWd#Z4M|@L0&2e(nm7?qCSsU^GqQaWYKFG+CWO_L21$vj(aCjS~GLvdXxXembwd-ah3 z8$NgWH2uXKfpH2W`xuh+_rwnHEfOnAefSYYmKGB&XYN0ne@N+**mADvo@hLpXlv85 zw=g?WHeNc8|6pi%(_d zE$w5AztKoez`eHZs_EfWfb1%3%Vm3zbGl$<*mz#8-;-7a_ycW^$AkRw($&4&oKQhy zUs7USI-NXwKSR)k6Vio*1lMf;Jd==Jzn!n^#S?Vi!VtZuORCZ-UXy)fP zuzgJ0;q~*=;uV!A4P_Z}4+cwxsLn-XiM7CnDkvrspAs-mYUxUWTSApv`^LQ!>6RN# zA{ADcYAF@7_2&04v;YOO6cLLf?3NuYh)mA$G&UI^bu-NXZT{4W-yVDXYp$Rg4Dx~b z3*n>VbJx!4DPqQmOOvNgpGlv?wKs!Bp&6B27h4pKt?soTwxaZ(3P|EZ3n%T8C<{VT z?a`He!*qEG^=epgh5Ta3rp#UQ)mf#q%)s8KrET z<-)$Q0Pj)h>*VMq&rv5)K{bu)emVoL7ncY9SUZ zHGs*Y8@V)Ew};<+@9!cIH41g`79D%;;@4^$dBH##NPRKe6cNbzmbcm#;EmFgphXUv zQ;XOH`rPj7t9s*LS`8B?tj7-gE#6;1S(9b|S`!$9&SJw64HE-S`_`;L61raY?%1(< zqn%?rmES#lK4AD}YaDUPHmPI_!B_tlZ$W4itN>c$hO7J*C!_qG!R>GCfCDU-;SzJC zt6^@a?sk?W$egQS+oa;YB8BlpWO&&yr)pH9tu}k|@}jIiHvdw@pIBkUb+>9)WcHQB znU>=gy)i(>nfwbB?;G>}`rS2~MGq=r5c`n+S#s0)QS?PJHZkzI1sRIMZk)2xql#=> zWaRth=O^Qr&B4Y;A(U#i{F?9Tc^r)BS#S&ZVlHX69Ur=^?$uu2@@Lnc!~hQdSeLHu}Ja|B^5non{8j)U?$uoPO-@i9W! zREfORmK&^=T)O>R&DFKwPmQc8JP}ApyFH#y=Yei}jy|$}U7u!AY9WOeO?*u2Jah~L zfzsP%W^On7CDyV=iuAb8voIf&bpmMNnbB!vm3XW7&94Kiu{X;`kYL2}Jz5Ac8Fh34 z;9vY!uTS%)sw61nI=O2unU zuoHF1g~V?`(jOn`rlXEk3`Gc8LBQulc4_p&9o<_TRq|@ZfP#}9s1D!3nSh>NdVqJ< zN~0_O1T0&9CxyT-Tfs#mQ2@uiqQQ0l*#SeM_iZnuxh$SZ)>yw%h({7oz<0F1fW0Zdm7Gz_sKOLtuY^>Ce}0K? zdVsGdaOf`Rfme%XbnpFn4nt(Whek^tfT|a9Riq)LLA8<@Zeaj@_AUeg9H&GjF3Rn= zMH{ili#XP|Tw5O7+2`=poU!m8J;gU4RixqdsOGiWqjc;&Ne(_n8}Iq{tA$Q9oW|>0 zE>SHNcj@R$G2zwwo=`Wk+Q~mSzHH$S^cn#j4t1^Y%Wp_NkoRv=?SL$yUilA#TyTX4 zFW%Z*3yZWDc6PzvY`(&)!1#u8N*iSIN4~7Oa!j@eNE+T*#WQ%)$s7$RFawkRDemv? zyY$2Qr9uiICno{|U;0T5kb(sX8~&$DVM7B4D7Gt#B5?%^8^}BS_86^Hk#_0snlPTR zaN($&=lA=fUS-H@Qb_z1^j-?t@1oH}m=gWGJ1EkD(jv&Lblmz_eLiOS9>x-5+xvdM z{34Z#N$XYASk-$M*+1%Yxu<6v(Le9bFD=`XySD6(W!u~-aMON{)Wyxd?oovvWIWO_ zZtn23dQ7JOftobntTf!+dAt{KN!(|Ai|JUohcsIvhrlQc{>CKg^CY_T#7;o;A5)xFW#QafD4A47P<9OPp|FI{z1aFsuN<8~VN5~^UctzvkH-01VF!P#q@wwk^VZFdFqz9)MLF4`0 zx54Has$k3V(Ya1prepmbyQLZM?H;C8)A&na_B7(dgpQV?7H{}hoEFDLwD)OXq=jjn zupy@<_$=S*(Rz4i`va4TBkaif`J1IN#JLmm*Wyvg_?sBi8Gl45bZz8p~xhp-~5jS$@|+j_uI>WP`w_V7wShbtS|p*#Q_5?Aj}s-@a5V%y8rk} zQ{(7k>slAxFSMjS1Kng3cz{FmZu=HFTD{1nPkKLIwJTFt+zLh8rF&p1~v;ks$VTDv{w*Wf-`EQ3$DF7YM? z&ve;k4M#fFc#ICdt#5|fYsaxA4;T5}z2gV+Pi;RNhHvA<+osO0_nq5~IunjZ1274r zVW!y`0*}i3HqjLOgD_vO4b`Dsv-9tkXWKZ0e67k}y*WCb%86)S2I-who#)ZV`)`vy z4(%GUtI0RLSBdn;b+~2KHxrRK`@W{>d zx>_V=>DDn?muT?^8!17R6(>2%W)X4AfFKfJKr9d_>QBQJDtPda;7^W#5(Zj0NHJcK zI0b4(k0bjgvAw27jalqpNogA#=!*8y^Rw3X9R26ZN&ofI$D!|++jUuMPtt0kU)lG} zRm(orYdkof!I~lHJH`HRmrdjHk^ZoQ(COWISfG3sy>msmK-TphN>wW9tFtc66eivH z1@I*m?Duug+VZq59lu-EG3mr&-MCp(WWNBpim^*gu98VI-CO9!Yy!Dkvv8CoCO4P_ zC*T`p5ti_V+8+o~QD<)F(_tS8l^hyc1W2%6n-&!&L^!}cUzYlRyA~3r>csT%{q2o! z-uMy5Ke*5a)YWxM64+Gd0yB@-+~1zX%&HdMSTDGee;z03B+_^(iu`0B z02DUNfJz8rw;LnQRHJ_b0EnZaAD6Ygzks43|19o?d{*@T0yT=A&nYvvTa(FK9wl~> zGE$n|%jg7%iw--kTZwNT?vpzoCsT{j4Sh4)d0L!5{|3Bi=ol8*!_;gw<4*>y1?7mV4(vN|l-u#bk0o zPu}^l&#`B|evJyxcP`UgEnYm|RyQx^MJ-?aU8lcYCo8!n29-3J0Dg?A2c6eWPgcFv zQ9ag@O&!6YYI+FAWCr{smVv;Pf5zRcp};?s0&`lb|6Va6N=b5L$l^geN8`uGsI814 zPn8tsM-5m8}f{x3JteZGng{zv$bdg>l+_1_qxHi45|xnY*1~SOl|n&Urzz z8rM5b&ju97IhE=+y`8VusM6bQY8t1PA)wfB)!t_Q9=)|?_#@O#t z`n97zQ}%VWA(Z07l#Qb9gL4$=_Ny3@eGJuSh|_-1bL)}O>jA_Ru1Q81H`AX;kRP0$ zo7%0upwwE*HHa zV(CiU^2}bbf)yj=A>!|+R_|VW5-|5Ga)(#!I)>ihdV$05HAOH2CHp^9h!#VZxnC(; z0y~qTi2w?#z;n$|5&aO+G5#P%E1wTS(ElX?fBrb2|F^{dF9~S>^X)^$0uv@-43{Nl zfzc^>ne{xNsEeL(%9OLB*E}mICJ^=RGolcP$eoM))*_g>61ynq@HmtfsJry%L^<)B zvuo>mo!j1EX!Z0Qt((8lutR^troY7K+j_y8e1Cm5a-H4oeJ>DrG!jT{&kq7U)Xa6O z2a&-F2Br*)@_NCYX5nU4pck-Pr&pcVh=EX%QY<`bgRDxDB5?TyeQ-04^il$3;dz2w zG=2p9{=A2#gjO%S(;E&s8T9_y*ZcMG-zKG~iVGG5{9{B{Bqv4`7DA!ax#!?;;XaOO ztI*uSNcu%zE1hB_-h2MoZU1gh;1jc*7D=GjT=psd;cRvP_(lagsA~zamFMC0v_8CO zcT+YzX1zr<%JW5EUn%?H!yN849v^Oue^we= zba6#zeM51B3-m^aQrw#0>ufIB^>okM9Q|!v0>n5`)dS+?F|xu{XL&`&&c+b%4MOZV zRcXzcXg5-4aP>TD1kdQ5&M)l{B0CrjLWoveun3SJ7K1rDHCSLUVMH{8-EKu!<$;Zg zRD5D_Rf2hieuo6^b6?r}^U2TD|KPo)tl`6^N^8+!f1U5mi`Vn%>x<_8uivm-uz*ev z>?f;LR&gIaRkcVPzq;9{Ov|S(&%cz?GilE5F81I546vp1aC>9ATyRPu%hT}G_qps- z$|`y2cczzxJ@3)@+p#joBK6!M$iC$B>>F@*k#qJi-~ZW&{Wo@hPvAaRq!3F; zYG(quf0cFDjj^e~!co%=l47xe2f~eUL zplcKIMNYlOk%2)RzRLId*)MOi^KABvV~V1OR>3=$z*{fS#(7r%JIvnNtwunU06$(E z=sboZ{!p!p4%+FkN#TMQJf9(Yi(vX|=^;}nZRR91R-FC`t}qB(i&)C1YMVtu*1iF` zoAzJ!s$HhKYp_Eh>6jq?u^i5L&PAU@&MP}?!+>K6lb(+rkFBEUAe*QQbM_^Iv|B>~ZoXo}aWXW)ocJh_SHaxXN}xrgOpSm4uwEg^!E3 zt0*+8_$#lwDQTO}pCO|7sG+`WaQA(+2i?jr=(Vbt!{TD0szujY+`|$*ugLwSp}4Tz z)e}R&r%>#+eM0)JDs8tmU8BYpf?4dE8WA-JKr0$|A(CxAG*8FA*@UOiZU}WX1vX0w z?m>4?w*z!BOiTEyroEt4W3`gL>qg;>CR`ossxD5a#7o4@dK*gQ&&`n>MS;Yp9M@<* z97AnYVyFWzcl+_{1&b!CC((-1ce6nMf2F>JQ>nw1?_2+<@fmDdcFwGxIP zIkDkQK*mnVbiox z;BbZZ`K5k^Nnio8;@BoLf8UUwtlb1L(zwG;sy?eQmWdawJM9RVWc$AC)09q}sF(yW zn|I{=^}>%l`rJz>k0V#)bFSvE=UyY-G&8=HJSqlzpFEZc@{V5IV6ZxqLnC!8*<1{;#swDS|uq~f*Fpw|`=>El3-#JV5RXMV5GB*p z%k|+)mw5PfrBb+BU2JRw3zQ>Ae|tn7%aYKAspe{yYPr?#C+N)2=vTi}gr9z$0IB?N zMc1T|f_DLx(NP<2@bne4gu^jW2q94H)d)pp+`^y6K=(31w0%t;ny_FRYgR=E=(;Ba87Dd%-fqg9R_ai4mVzzk0Nm(ymK_YZHB=Ulve%4>MiU`{3aDQz=lrQ`Q%B!== zVw6{T#D(zf`a}Aq#7XYEX{*l-eb|Q&ujV`wAhys-W(mfR8-~^cH%&>}OpP4#5c#2h zVIW*RLDa;SXbi)jh{z&LkWpLAVzxeer*#!6bgj9Dkg2sc!E+Hl3#ta(jJO~=I2pT8 zwjIA~bg*p6J;_UvsB-DUbO^TyG3tvKc_a5oVE$qO5A0QcV#{5+7aJ>&Yd=-6;2eeo zd+8Pu++rt!vPtP^jPTOSNb=1iZXX~34)%ZxdYj~( zlblMn@yMAXj!|9pX{z2~FZp}KR-3hF-?5P`<9^5O$&Ve4;zY{iuh>!cpy(|k>}T2`g}A%C ztO25^N~8l>RVsiO=>9{=m(rCr-^Vx)Ohgk!2Z#(*7Jp% zeCAm`GZC(_K6y4FS&<^}t&-U<4{G*8Yy3AxIk|`hn|Z6e8qugi<8Nq`xnecBV8f17v)ZW>zHHODe}P;L8kgE_(xiUck05c#JaK>Q-BgA#(PO7Nw{6yovXLGhulJ ze8N4ct{Nbz#6bV?md|i}dy-Cl^VyEugu&)9NU+oLJ0l*iHpJrB z(Y=jt`~#TfNt#~~LjScrkuq%T(kj6)u${IN1KIfEbJh@SJa(G+NTIxhB9|tq+u&jA z(AB#<9~}!9ool`Xim}z4g?42+c=~5KA55_7zgUnsFNlye`SFI6H?U?SS!bGL;BE_#w>9bSnDfkJRfiMa;*4TaqE5d3ruT`!&p61N5utAWL zCd1;*XWq8pOX?0NqL4@wkDtrXg5n#85|dYIHiSViF09)v^DN{%VplB__u7S0ai4dX z3vU`?*2{iodxMk2x*w7Vy_h_yH9}$G0?Z)*KNb~$JKzK$obiLNsAM52ZIYoT$Ncpf!&hWu&)Q+Gg2gZVCC1C&qfxQ3_;R+NslMCp zY9=EeD_1N_t`3%8vU(*hgM=v96SHk0DVcJTE;cC_7Em!I@R#eW#L#S+$zrM2MdFc4 ztDX2;m{m%vjVeE2(h}4Z$Uh{V{{@hF3W%TMavWH+AzVf>@^73xV(yVx zCr)Did0e57s>#y@2%NAedbn#YT0%4o|xN0p1il8D9 zDU};*YvpP3A1p<Hjk_bji=B?1I=0gnZgVCoIj}?Z5Sn>~4%q1V zN6vG`k3P==!bAqMoTQ?1h)^JJXIiw@c>9L)x04jBxmBAq|1SMW1)8yevWP4xo{Afj z0^eC@BTiakeH_Rm3)^C}@J~VKoa08p{G;2QJ&&8{YSn!9<`(&0)Pv! z2kM8&hY*DCM?fuX6o!(N>4M$KXHkBpz~-hX<5w#?Tx#B}YnQ`!h}6KVQFFZO(0N#= zYcr`6PV1Q3xO&17m>5+(z#_97>Dx1TJ8NG!Qk%G~BNs7$KAkH!EF0m^*~*<-?&&ro`N*!wRRMRwe$Fjar{ zeOoH$6w4n1tN57$F9UMr{fRvr^c$;@o->H3I?sk{B^T#$Pgv<8;yt6QD=2^Q|KVh+ zkF9w?i2{9_E}Vy{0CqyMZ((AOcv{xklrKQ5k=J(m?G~v$Yz@0?5@x|iT3}(mF?-iS zf?qpS&E=*W>Go^AHReX7lbF{a8dSpB=sytZb5xNjP70-DB=dxgmxBiYZkooD6Ufyl zcW_h0K29+`bRG5yG7Hw%72h4>6-2>gwd$hI{`VHyjGkPaL+?c>SS$RDGr88if>Us;Y_G^mZOoG9`ZD~r`-r7gV+meLg( z(&H(VQt~I^xBd{ZOt6N~+3b|b5zLc+dTLEOA^-ieM6ws_39u0og+I(GRZltviDQ|n zTL(D`+}HNWMz;%^PCur4+HYFK{tYu!(@mx5dN@ZDY<(vaL&;^~zHvU=FfFuA@mzNe z#Iv!^9JrbrdGy`THC71x&;;zsjYhQ{4rlNJ^+rpAHJ3^`XT4|I&r<0eWz1}p|INNh z#cf3gSidRyJBlbu+^O4{)D)P9jsfnSC*Cw_vgI@Cn7CxS#8eq3jEhq;)EcD1&N4ye z6HKP^`C4ke;fC=?0n-Sd5pWeA8OU09;r=QPr%J7uTyhHx0Z#YUwkN0T0{XPh2JZ6H zGxWh%s8}WOYFo`XGqPT>1b;C~gocW-j}>cRLUtNNU1|=emd4V;BhQFXR%|pRv>R2D z!D!CJ?6#U9&6N_!73j5bP?@7wpw(e)yaj*56#ge>H4g-->n_DXoqiZ4G#E5|KnJox zi3t2qf7BHx$pH{t1_>e+5?71rJu-L+eK}ouo33hkaW?YglsMgaASON2t(?`cL3BLDt(?zRRU)M4ies~wLb0GN zhQPMj3_ZB1bB(jpHlXOaUrl)QFo&nN_%d7Xgx@zvtNDsr^PjecT|qCk$K=!624jZ4 zB`+E}M%(z-rgD*iVTza%+-kh)r`yc_KMTt9Gv@rrAjeYXSZySdc|)hs*FseY{Wh5J z5@{P7_T6BWX+(~aki6hkI!ckCl&kR-MNqrLYS2rj3y4QlMtJs_R#3l`;Aj9 zF&$W%Ql=3$D~Ds{5|)BU8kOirmlVf(xc{Guib;)5`!hrEuRr|JQh%zee}V_%Ka#d& zg6(Ypi`$v*} zW$0Du@rRjxT{ZYpcq84f8xbsvI|ts`F=}o9?YS&4ZwwYydOGzk-pKr{q_HYa)w0~q zX3)wfw0CDi{VIkJYMp;R@JJugTaSDFPe_rFA4=p0u^=|#K+<0Qe_u_8W0}DkPTIMZ zlENW1gY37zcEVso^vh*gNpXLsRRM(Bk{B?c!iC7`%H&8;A;Lrsdz0Os-*#wYr8(BadmriKX|NlLV)KJNk^?Y|XBHn;DF^nBJ^s2B|wteVL<|Q8>q5XN1U`&;LG#!Mgz|O zQXqS%=*h5_F#iDi(E{-6fqYNOWg$EP0N&?mhzArf;K84WKkLtg@Fzo1KQE7t_QOvi zj8GZ8Y?o*^Pd2{bE}mLm+m@q_Lw;Ve7XUna{;f^e$hCSA~#Ep#dY` z_n(L>#g=G<4eai<+9)8VT;SU7`ExGX{3prrGkMbgd266R3sBNUX{RT9j&s(Z>vR{f zJY3w;Dwk=b;i?n_d^#Q$hu%yd-?;q{Rv-V#@oo8T;oFY9I4`oUJjyMuk)?#;2KcU8DTM>Z$5IN1Jm3OsO{o3zkR|G#~Ipui24- zos@FTr($g#C}?JT7)vkcWN1)YfJ0o^eKGk95dLV3us=`6git*k9T_%|%Jj_Rho`$< zVys%J`#7B@M^8o~^3ZhWn|tP0a@(3}t1I@=->o;{i_>(5-$$OP@V!St=XsMvfWMWq zdReMLQ?+!u?kc}t zb>8i16Z#pl;P2v@@zeL?NEt=eQM9?lKT`76fnsB;5GDr~^*;HIQ1w@&W@Bd3myl55 zzojFaN!KtemVa%>YR6^vZ8bh_^tSLW`C^`?z>?}wC89%YKT5W81z$YajPE?+ zZot+YkY7V;)U`a+FrssX+;U(+w7IjDrh1TMd8=HZgBrG^mUqK8vsizZzG;+=-d^x5xGUX;w;g3=e2+pC_=<7_% zHQHez&DxShCZAgqZ97|B<&A6!UYa<~e9lgCJUeB#17faH^I}N))=Pvso75aSk&cZX zx$H7su69=Ql!S+}SlAINhe3o;p~Imk85(2W*fIxk`iO|e17xJ-Aj+oVO_Ui? zNnVnx4%}_|hhRlTj@BL9zaPWt;IaX4`h<}ykd+$mQ z_03_|H6IT+tvDM{6%Yxx8(S^UH>*R zSLcVPnrdEcea@CebXV&@l%_m!>8VOrtS7)$>O;w4n}0|}5%_>g*g(GbOJK0CccH9Q z_rNqSNVD4dzu&m`uq?Mp2W(^JKyxey2Z&y13bJ64DlCE@qUu7Zaw!P~gwFdl{G?8p zYK~gmwnz%x2LsuqF{qk*lJf$~+MBtHxAS4~#-V>m~w7PgA+fhN`-@sQuonLdUt>+M1%G$0tfZ1hv;~ zQaWv6HRiQkxPWPbeVdL6R&>u#$k-~@jhgS*DeWVc=pAq8xfxc3kHW|f`zmbbeq){X zL)0Xb1Q$hAc(17m|A3T7K%OG|+XnyDOJYID$6n8stR2#*gn|)EO;a~uT0~;s zBPU)uWnLjr4eQ_NBB>=?q~B;pyBJisqHm8DV>=lA&FZ8?qXJ_@0k~}S3~{YbWT&`e zFHGnnUS!$}x&;k|wnKk`io7TbXs9G@MT1L>TU~eL(xyX-BV>o_KI4-9G=V>Oc=#r; znhD5jKi?c1M}pES6@c=Fc2U7h)po_|o`JFcCIa($<&vC-)M11@ksz=j#8&B_cUtv( zvceqQO|Ofgp?X(KhjvzBR3R6O1v~q7GWqoInANv?J3HHMIBptvOCMdgppCQoeTr`l3$boq-dm%5}TP^K0KwUxD zigCQnG~E^@&GKmij}~QjT=<>`zwUUVTe0dnDv-SHW8M1+EDhI$@u8qS8CqDz&GWZRm=>D)hZx~T z^}}5**t!KXVyGfrM&2d~=7DzTSDEI_tBYXlG$rfBySX^hO z{2{AQx**o3o06uT_>qkEZMqw8)6u$*F)ZQ3ErRl@9%X5IhTl}tS9v1(6@z2=h^EI- zQJIr+b4ntVV=re{XfrgZ^Qxf7_VZFW?%`~A1oJ3sH00s^OK>q=z&)Nm*hF5C;(Ez| z_@M)n*3&U=&60>i43|0jPLmG}f`=Z?qoKiA;B#SiDlKX$;;g2F(%&%)35XMbT@m2Z z`zv5`KjduW2_3W-=-XpyF(IVL2tqd@A<3pWDXiaXv*lzy&6OFa&GZZ%f!waWildja z)l5oXPtFFISgw?3_4n?mA0EGqfo9%YwQzyvZQ?Q_R9eo&!556A&6sgnePpd6xWAt^>Z z>JfQm;V3M$B+tavPWA4Mx^ByNr;Mu3Z^e!XpZ5vr#IgUJ63E7y7`^t_f6+whmnW^p zZY|Gd@B~+c9?1^)Gvm$Y!#j%#lF+eLRO;OQ7k2Ok@vnU~ZoB0t5tRpM!Y{|j zCMc1I%d9+OAygb!mJ{b^^b(yHtUG^t0>{f8ZruTGsm7s(-(%u6N|J?Fe1$$?X&sD> zb&18)=qO9@Si2H8LfK|11|%JcTgi91N-|m;jZwSzNF3Bw)oY;ylE^z7xb(R^eQR!@ zs{zHKa~}SVuCg#Uuv9z9C-Fx*F@e`DrFyFbvWz=f$j520mS11hSio$leEz#EId$RV%;*l*_t0aS&5{gVy+;Ph}f?k+bJ}r7t8uGRMCRlKy+~x zB6-?4{SYrv=7MjkP+KZ1-0%giuSR$h7Fm3aU!SK_%Etx_lh3IpV(ftXWbXsFYs= z(802R@5IH|&fz7!l);{{1?{u!)N*H3-PXmSm7<33GJ*->qMxqcOx`eC&ZS8O=dsmT z)MRX;2FK?jKd)hXF*{lHaNl^o7DF1GE;$7;hfwRn@MB z<84O7iT_S7H)7C6FTi={Pt`%Nar(Pa@Lz=AM8sXI(SSoZ;*5jvE6Dn+ai+IjLsUXH z{>(8IeSLTyu&cFoZwSenyJx3@;8)hfXk62CJKT$DYj`t0CW<|gnDaC6)5KhS3PckJ zU~7YxaA&u2LHN<m=avdwWr^g(x!gMGBqK(I@=oFQsaC@&Y|b< ze4Hk)eu}mLsKCL}1wa%B!GSc|r(w)q534o4iRp z<7qD+lyG{y;napHttR zMUZ6o*fVwBuZ2j~dOACPLn)xB=?eF~SNP+xk@Yqh%V6*+y_oI9_UhCWZTE%NXs2i0 z5??{Egv4fgFth1!s`meozW~t20nKeSp^7x3QqV5Tl|cI^2&4 zB-pP1Kg;r-V81o(&zUTc^jEm7Fq&)W%f}bj0ngJ_w!F*jyNlzQ7TqsbHn)Wi`n36V zn~S3*1(HL+C&5RHoFAXw{szLwBkJHf9lqH&U;uTd_}%$|po9~w{CDsx&-Q@?(aBcp zY?_IVblG__;EUU$G#Ow6Z>(Rj%cCb_hFK7vCrY+sA>&}~djc^5{>0s(r#aGPrPMQ$ z*vO6P`~^T=jl2K^ZS?~{mi(c{q6d{<96@?I36Vm;KQHXQ`EEgfXw`)DKhT@NfFCRN zk1q{2@JC;X!!1$LLE-8pGsbXik7#d8_^Kh)*;#cw*pTh@@y&Pj%fBv+aGz1-V^_0~ zQjoS?wKiYf)e8twgD(8`!Ws!{I=A%mkNd!j;B{hr)KW3b?L~Mbm`7R@CB0N}JD%&4 zmQp_cBV2M{q!p=2 zwppU~E&rj57%8?^8BJVuPcreA*?;7{TY1gwln$pHiZQwk*bQy&El9{6MQS1d+C3Klg$ zrpfNuXZV7f#)Lo{`+Ye%hKK=3braFm+b&x`PTZEtkj-R3y&Vk zyJS#i+whk>J7m-2vOQu7*yH=>(-jetu+Yy!p+o`)6|yh?aa=QniyP{3v3NZ`F7+)w zzRNxxJiM=R@3EnARH(IcDG(mxcDXNnw|#m0dEEh9==iPO``&%q!JdfUsQlg;Byj!(=kQ!6<6~lzke>!o^9_f0P z!jzFlIg;X4gMKN3Yy!F%sRA4%0TM&Ekf6{ZfkFB|5La^CpY|mKPIXqC|6&M4l%nEj zh{1zIiX9v-=WJ|aHEs-LJ}ytowY6fFDZU~H6@E?+1bNmBookJm6>-&P#H>`LXS*tGCrim_Wy@o{6&zvo*9oXD$z?G8bSNLLvO85k}Rc?j$XfqVG@ z0Q_K5SS>niDBofEeXdVIz<+Mcm+#Myw*dWoZCXhfY!IQJ+p0vRf|NHf8kNo9?mgC{ zlcGX1O&Kmv47+%YEqj=~a(<`3J-+ojIGwd)#ruM0w#l6a;&>4{5al>n^KmO z230F~4WGGdk`JJ)4ZaiIyB$@5zG@~;)0{1-h*N5ELpsL%WO)yywFL=J7fZ|NcY z5Q*u(;b~U3dDmK5pbbM4Yw1hh3ws7nmus>X*O)ZtpAl{^Dofx%DcQh_ZM%3R1aD3I zHd0bPuTt#Vn2(GXDAo3K9o=8llt);PW4*=16t7N-c3hmc`y(Xnlabbw)@x0z@LCIG zT}grvj?h2+c&*fi7z(On@AjMWPq1i}IoPIKEPlI5R>)|<7E5EFM0j{`Gu=eDt^IDc zdLVmF_8*A<(MN%R{{xr&=d754r$36}lJbZuF1Q_J9WgyIWx=v*xs-hsLPw^b!ZCnJ zLv)6|n5=lHj(@p96mNl=Sb{F@i8qQ}>O;C3uW;!Xf69)n^h}?%b9aVn%rH|Yfw-|3 zvV8DRsRQ|C`o_sBLl1`&%!edotag&t!``u$!RdnbuU*FF2l^7RWkaautO((*IU$$~ z{W*ag-ka`i4yYkI@ZDc8ybM@$ykS6kEtd>K$Gz4Ud-j}1Uch6lrD7GL!d_mPB33L8 z|1D7VjUhhGK^Y#&yft}$Tw(@b(De-VU6L)_9GsSz0a~l@KU}Ow;aK~i>~%)bPpO=< zy7CIR7+N6!gL8PGKdoAJJE^c!$@BNTx0oyCFZ@h`at=t;0zO9db z*<@GQndSL-HLDx!JoQ)UD;YXg@}HV3y0d>B&ds3E{T5moS{G54;M7jtL_Oueb;eGD zFU3B}n>SML32CU}+RxlD?6LBUKrW)KAfcirqn0N22k<&<;d2I+K}VrVi&oevRn4I= z`~|h1Jq+&WPFJ13m%wR)`by88G%IUdP5S?MI>+$H+NjOOwv+DIHaoU$qhs4nI<{@w zt~ec=9ox32-fw2UfA#ZR=c=d9se9jht-HVYDI>j%Wk2-V#|$M9mprXIe_tn=hW8)S z!naN|=!ky^UDuu@ET4^?#6iQTFpC@)1buE1yP%mZPZ@Ut<7hVtGkkq(JHf-$=D{dkAek&2!bGj+az1|pWaI)& zHBGFlzqq+AYj0=pb!|%A3a<|(!$gomvwdDfU!L~0_9FFGYhwl3lpJU%p#qVce*XQ_ z#evxi=fU+LX zIXlqP?Iv{;jmK2pZyXL+sV}D&(z5s{9S&2`o8+-KKHR5p()i{_i?f2Mr|98br&GUi zS=sFlpR0e=S-XY0}vgUe)wm!251;2E(6Ef=Q$yo|PcIF^yQ42l7CGIi0 z7WQ-juYmw&hq#x8ws=bAS37{>Y+z+JT;;aPT*b0?&V>^8rHZX=^~qe=S7zXC>yY=N zH7ytB>n5uG+$++1|Np~FU>1e4DE_yP&1z>WUGk6AZCZGXOCo3zQYXt~IX9F1Fdo&- z7#^9*P;a~9eq?w`H)reDWz%K4lc=(fd=Q$#F4I#VZ~IJy&duLHA%l8iC!jK*h8yOk zMs;g0{sb@nQ^A70e$|uBi#?ARIzzQ_mo=Lt0Zs)*P9}Rpbd6@#TV*1o)5KD1IgjJ< zP;KC97R{KxC9=&>KYT3d;6M@a)>6X%tYdQuX;wBz@0}d(d_du`<92_ka_1~e%ThsV z8Kw5TIE7-BQm2?=FXq+Nd-t4+8~=vcyWFLE!5Y25jf6=snyBsDW48E{8fYB_&;cg& zSS#E&Wf2Jw4!7gtF4BoV825j98HU;Tgg6eewq=4y^GMH9yEy4o5vvV^kY$Riw%Uz3vT! zmZV22HW+06k0Gsn(N1$rcV=Ax@}^fu4DfjKuz{r8eob8RLq1Mt=&brX`v_`h;r5HqwS5Uf_1Td)FIw0yXU|fnXJs_pT`3;M3bQ{}*n!0eDGi2F`ToXo?)cHZVN<6$ zL%`nq!A6v0ApG}BQ}yYN?7jHq!oF}-`ueIW_*Xn!18cxYOhKh5z*pq2r>6lprdy)| z$@PypOdgc6p=LWtY3&Znn2k5JTEy2?^()TL{GJN(yEdKPzTx0R*2#tYhk?)C!vv$q zXX>Q1YwHf^E5XxZn^<~w)$fMHK&0dz&F-x;!I5H#-!xRCa5lSR3)o-0-0!^D$KS@Q zHVIt!S({)8am%Pt{zNKkr6RN>y9+HT0W^oko>MwBrzD3qw%QZ_J*RFBMe^CkomU*Yo$w& zwpjCBd}&TarW3ag6a?areYr+-WkdcwjB*ve9ooVn<5Tu>UmL~hbaTB6cS%K z#qa;T_HHySR}^9>($ev!V~ef|`ooZ7(PP9{|j@r(1+3Crap-Fs3=A9}646ZvUt2fkV z(X!Q2e^>59M6LN>#nIHt;iCF33{6h6X$V^Ri-0d-TG)~$NTo-A28l0V!cuZz>osJpGTSiBfG|5DN0AoiC zyAV2MX@8`DqurJpao6a>)F1u?ti2(#y6 zwLRJy%GGzB)iq8oAJ`8DD!S%ol6bVKSR_jxSc+D(Jk5503TCk8pH%;|b4`1%-}367A$54F#gFwT2*UY9FQC z<*2wQbFFN`3&wUju^0qlc?zmj*L}5hzZ$AJeQ7h|Ds(SVnTE%NxYb{W)zWxE;!{AV zigj%I2M}n{ROfsGI%f49cD_ipcC%S7nP~-u)d;aY#-0{DgUC0{onG+FN2MOwp?J|7 zAy3HpzfF9*OR~-W7P1R;hcN{Lh?IXt;-pYSjxps}?2(vhZNa=fD>j zKioHyV;Wa{w#|3f3{Q%8{f$HsnCT~y7w5AJR!&ILg-ZyHF>G{{u!V{Gu*hSy3f&m? z?sypW`5>37SrHY`4=#@pfqd`C3GYmPGvTA2pGo zBn~n@%JB6~Y?@U=wr8xve<${lp_x zu9#|9z7!!%FeaWf%rc6+0~myOAqAR|%)Yt((fk+}#2m)0oicl#YpNg85_xN>d8v$! z@Sz?c-Q)^xOf*YZL||XPh4}0#FrrW3%Op+1Io?u1;T7#yzOyQJW-Cb%{savO_Qn4f z`hgP%0N1gBa7}R|pr@Gvm6V{W=K1MuCg%Oq%zXIE?^=7;ozchRJeStN316VJO|D_% z@yqqoT-#lIF*)Hf!rY71=dM-?z* zR501ZyhP4dC+$K5A?CmA52G6xvwbH4QY6v(r*qoKA z$t+n8`dyvMz-pe>+(&w+G0M9%I*uwkqp3`J%Xa>wA;v)wn&+_nATxvh0|z`0hJC9{ zz}{CV=I1FjZLj?Cbd-pk%~9<%+FPM>ZeY1akjL`jat9=l4Cd&6XjN3B6t}y(5@+vb(7#V zLJ81sA!}80N@7>G+V?_94VJMKwl2Pdh+hGMSrNK3*SoNvphQfxMxP1l*z#c<`gVVX z|I8e_UaPbDu2V?izC>72b2$>t)eo~Fph#RR3h{qJ?El;OpUxL32nI?u&~6wE3dF#$ z06+W}hy$^?urP{7C{0_BDf)nqI)D6qjqRqt7-_oXx*1-0&vxHkJN_J7+ffs%j7{H{ zE@9g_-nVS@8tQ+)Kih%C&K_iIY<*3;YCziI%=iB9pO3`Tq-V4|MoPy%qi~oy@BS6* zv~Yhom0-f_unc!eCsr{f7N%A%I7CQZSkH(a6o(b+tXBb*irH(ugmJ<^TU~|UXK)_^6+#34 z=V5HM5i1{6*MS|em~sX<&R;ILyK290@|DVucb<0I)t1zX*6qyiv=xWHDux;x4}-KZ zOW&1OG9=TJP};I!Cl6+!mq+2`^O;6rpF!M#sl_KR1PNr2u~5B*Bn94oJNjIzbijlX zc(=nPfy|eXArhu=eHt~#Jo_Gf`!MH;JpQVw!}gNB1I3_(Ei zk3wvM_wtSwltegXnMi7R07^!&V_tv4%^y9K!Bb$R%NDnO3^pY!)%9rq6=xWm) zbG6}^^GhUlk1vdS<`jCqcmi&WOlA#jxPBl)3<;%6l7!W4$Ho0zY2!#?S?O?9K0DAx z=OWCT^^5d9+HOm<)1S(_^_zMhy^n;{5?pxh3@&ou^J!YnUMlo_=rNC^`=h!Q!KNrv zLk9Rn?sDpPdgV`g`9A1fR(bbFCE(F$k4zO_kO4DyqKgi8)UtBEV6;~48>)O%yCLj{ zy!zWg?Of=QG;F{5x!EVK=4Pt2HF#CwDie|5i-s`RMaSNEvvFT(9~!AztSU`zBs#&E zM>ij#5C!vpF%Tec7#MV9@Mxi6|B*(3zf55L4!{EqM2-jt690!$fC(7@e#LYyJ7ce< zy7pb2-D_PsCl*`u#KOkE{BA#Ma{f7QvUsG7dpAW`bC{ounb}FiM*2TZwywV-+Esri zQSXqhy7}|Ildk>}Dm3<237+iC@m`pn#k>ptCY@wp9jEDQ1CoT%njm}$f(VrA?=q)8nmIVXMt?RppzHR|)J#AC!v!5pVJKN3rl0e!VVM&mWz%6kF~HdN?B!%bML~;a$eh#dmi9hAm*rZ zt2Aq`vbIV@+ge_sl6(5G91d3pR{mucu^8AYA zuDE0J3sls9?FGEIK+=NWT!5q9WE$;4P2O&!#~`2_GnMh|s>yVqMXtbmzFc zuHBUGuvDKiJ+UquRQK^N_m}%1zW^_B8FZ_I=a}NVd~0m{LN5P?e)b97yixXqRugzl ztCkg2^pLkevQzV0+P3Gv+VgKSj`Wo|MvpH(@)Yrt4OUeq@fuqGE};hMo1^`7Y>kui zaGP`KFmQhDMaWA?UAC*No&+Gnfduddz%CSv9?Ur1G9)nzJ`YYW}ai)H% z-Xd>z%CE0DUEiGkdiP0mm_bzC#0ri6>bkkFY?Br|J!UwMD&o7^s=Yn{Bih z-;G6WVOH>28vS_9^3lP?EG^wOwY$pk^=2CQz9~*!0(00VO`N4DAElqS#ayD{H~KDh znZVpYm8wH(>=1unelFBkYB}!mG3)k4t*HunDPY#v8sGu^s>tG? z!$A%G$82&zg^>sK@R0ZT@Us#_G?pftNIbw1u$5o)y*T|Q_>>~Jke_PuVRMj*U=@`B zW9Cyk$-Ad}c*4k=-qdSMnbCskyUEOQFy{W%QovX)* zuXup{s){CG<)$;1Gnlm8kVbd+F(bH>6%LKR3860=oC{YwvH2Ds`Vg9qS zBI84^nuXLk1zrVQ`WR?flE)bv4}|&9Y;z3uVRLqo3YV7RBCrvji?Y=1f*~8Os81V5 zh2Zvi1k8#PF%oP^T'O8ejv3(S>wL&98r$7=0B&ur#Ludmw}d*a}!H!t-o@1)K= z>U0g-j+oTFFU*UIY4J+$HV3xX-LsIB`(Z4sQ>*6@Ey4Pb0pk1p8fI?vXAVj!AOX|~ zoyb8oPjg90FcZq|VCULVo}`x<&3a3QouiV1&)+NQ*bAf^`Uwz$sUBvka>^Y_OecRz z;K~4_868A0ME6rgnT!ayeJ)s_A8Xste>>z5uPa4dquH5<6Q2qleL9S(^u$7;9J>-H ziz4R~%f`DTe3R4w6{il$b1GQ4VP3Vl(*z|jUUFoGpE2$>Ux@ZqNuK#Gf;oBX&M@C} z6aiwmYD@;^gEC;A5ZmX~-NB%kw(yIr>y|$ng=Ry=KNaB)KZf1L9OD>AIxPU-XS9qM zLx%C{YLNSMzhy~;$0`g5aZe=PP}DJx>QZAVByFPZt3d#I|E^-U|2}Wnpr$@kp8HdL zfzT?{OZIr~1TU;osgvX<3UQ2w_(Hg>HtZxKzDIf!n>S^3rm+818g?VNRS zVScF?+iJ^y&;tU5_Cedy99cSVbHe;ggRSj7(i~<$Er&M>R6rtMk!;v-0c?JrAJH8y zdB0jb56gh6Lu)Y%=!t({Szs=2e+_U<{v0ioB;yCj7=F|T!k)YB!O*hM$A?OsYQL%^ zzMGqpuU>SFQIAsofS!u4IOk944GE#*74L>nD#jd>bqK)PH5o8wb_;~AH3)U|`m!14 z;|lF+`Bgpv{vPahwAu+`0?-;>ZNVpH<0JSGc9QhqHuL&P0yT>fMhbw|)-rr^-MD@r`Z=zw-H5tNo+yE!AO0lh-J=Q@;Np*apd@@edyXm|$=c~e?E;e=mfEhPdO)2(CKv8JlWq~qEk|$e0%@ww^)_f<93N%^IlmK&*tTgjetx| zhTB;j47D6Sf;SXekBB?_(xBdROu;=M6v~*c?)mUbTpbt-{&j!!PJ>6-|CfsBWg%-b4kcl&giDA54KmC9>A(2_JH1nwZ zy>tK2%JKrQ^cD=o`p+0@E}HV$CY3-5O*3jSrZ-eutPrgYz22WJ%tOF{5>wvzS6-(+ zCPRZgt@Nd!qB{YlM;vxN64=KUNYbdc)A^qYj&_)z372%bweJ;BJ15NRHe6G+ZCcV` zJ!rP96KCDw0f-?RQzs}AVhFcHDeXc^3zjh)3>`VIDR4o?UyPAo_R0Q*Zevb#s?^Hz zsN>qNq+CIqtm16m1$j5wKht4K9)ZID^YE9mu*82375wZ$D~hLWaNy zP)6#<{qx)~1QN#!ZGHkPkyVpFGyEJvn@{y_ET{%#_C`d{>Oq+uwv$r=T^KcVn;&xj zT9j>Ln=d-Y@KZslqLak&}K z(RX}VHIIJWD=fKI)JQ#LbNEhy^?h^^v?kk2``p`c>8QqL^f@B6PhPL}Q5mwaqN&F$ z*p4?ObNO4g^$9(G%2#JkZgCIEIyWzW`Mix?q}<%yW(1+3e;bFb)#KBb&O4W@sdNN7 z>P>aCf(^9m6b^l&hVjV1&AV=MoPndS5OPF9VVC_0b@c}j;z@PV=rS9}_b9n5Ftbcf za%1Z*Hj3%)Kl!?)E4tOQr0WaomcC@Yof6c54ovaxqBtfZ# zkVN9SdmFEy^MV&mDPTg?ZEJU}daYaOC7aO9c$p+f{Vtuf=mX0{|AtGKK=>)!UN)^V z8#+ph@CU{uTLmj^NL5`}x4L=)yL+?Go)o761)}t?kO!2iJS8Vng5~4-(FcgE@7wRn zvGz+2siK86vU^D9D8bw(EM7krTV`(8EMUE!!*S@;E@DiaN}R_>&~Pz2;trd)%q7 z)B{cx4&oPo?!7jx=TE|R)wWTAfej#Y@24}vt(Ap5g;kK{$S=;yd>#&(FhpufhF4Nw z1em-{e7-FI>3^oVlqyFz{2etd?3#=Ay`9oww`U7%74TL+CXpwjrtv zr)A4KI05u@6lju12zOfC-J77;0no&lzY)5u5S{#nKbHHEqEu$*i@)CF3j!(MVj6H~ zB^}90*aA7_;dtT;$OBWM-`#6{&Vrzvf%c3A6-X6Ys}6CYTA-h#Jm! z+I`q222#s@~u6NoFiy-YaJsOoo1wRT; z)Wz1Y4jO9YzNfV`@6rAjcAvV>KV?w5-5z3_EhAy1OLY`b%E3c0gO8P#qZGZ-jb21b zY4rqX1jnU=%ygY|b`75Be^5SIYY^asN5i+ucg7a@B|{%(L)5RpOJE?YVmB~LxKl=B z1rAy>x2PjFP1P5t!OtlM&h{bB-Ut7+SA8I!`8=Coi+Y;BaDx^~*SGg0%yewkkM|uh zORUXb%Z^<6$9CNf=fgv4f zJ6zy`6OIoIUc0MuD4+}9zMU6$|N6_O{xfa}dc6Q8H&MJ=Ft!9ws7hrw8l3A

    F^h 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�L$2-vNN2qd&Na zek=c%o&|B7s{Cu8z>2HHpj%HFc`7WQm9l!VdO66-)WEJ>DZ4lg#})CFmHP%zb)}E$ ziY#BfPwl+xXzWmxU5Y#e8NJu%MxVPp?;L@6tb{jF=_oVj+WkSsf59I?b;bOV_UgD3 zUpL+BFqep(5I+?9DhwR?CrkP9$xPXY*nVjE9d!*5c@Ge!i7U6s)s?=HTlNN3i@|&= z)T-LtO=hY@r{Z7CHL+TDp4%b{+s*%EjiBX9 zWz3MWO2$gBC?7_L_~YmnlAJ>d0W*YO|JRoIfGd_w_do9vLJLnhm&HyD|0iqbg!`g& zKg_?nU45p2w@DXy?^DCXf;8qI#(h+tHM#<60#tOFrYbFb*7#B%RQ_hk%YJRFtBqCj z*o`c}=FZ2-{zC2z`(hY;tUb0RIm&HwM6AMepGWUe@5gu<S2_#_#oIXT=tldWu=9 zm>A#MuXJd+A=e9R++{nLtWKS~;c=WxN$fgT$rqrlWm55yuDLVX z*xP3yld{>!8t>vK)Zj4e>PngUI+St)QI{q2ip48mlQuU)0L$9g{8^$7&?R((4?xgF3pB2L9V z`>-4u`ha5by&p}Dy!-V25zc-=4V|--xKs#04WzGn7joq#;fEnH~pX4G(FU zwa5K}C}TLspsL`ksiUpc__vwH?B{bVYsmJX>Wd6bG z%+NFL-D~*C4-A)Z?5z?cz%Si9ixB#YQn669Ou0dqAs)Y6#Mbyed)T(XkuXH7+u;R0 zUq4o$xxQce@7DJeSY$uR`)rz1Kq3Fz)55`sLGsh+@sQCViO3mJ ztY{V`3O{N)mlnrH_)CTs!?RH4!F{K7n-xz0;CuQOwjHS9>JKqKAjrrN!^!2mT3F08 zo&|hQs3H9EB2g&iN9Hm0zA?Y3GRj-*?^rX=h~LNC$w!7likERBjv+Vm9^62DK~H!n z5p)@cV6U@)#_YQ?g(Z&+7o0jWjF(g;6ZLK?oVExU=~cR{(qBzL8yG&&A58OHM%*V8 zxV-7!g@X@IQU7j%i#I3WL+GxFsE#{6F&czKRIeiUFTc`ng?%-%#wo9o{r1GuutYW; zPWYpj%h{l=h_>(+8DlbFJvaSvW#+;kxMY?}yx;MY6OgGcksRpY&YfzYUi*{QfJ*JU z60q{?v}-2z(#UX8k7pbEzJJ7S2>9sLu_EUBbZi&zy1CcD-H(+P;Wz6qvL~S(B<2A~ zDKqW-YF#@IJTFtVlpPbEIL~30n`P_G4nfxV+@nGlG>V9$>sqKE!qs7b83d`8XL0{U zUj#!`zJE+530w}}ag8p8WEPE#-Pju4Q#Db6plZjPnX?B{aD33s+tsWw=qq~qOUXUB zMREz(sLaDKfTVIJ7r1zt+{v~%0UtxIU1z(@W2|RU=0|S1)bll2=@3Vxrr@=X{Z7z% z=M0cIoc<+O(&;k7lj^$(9ysR))GFa>X2BMY-%;#!$OofiASLn(j&!GDQz`PzAbUwb z{LWCK49i0DU{Bi}_D}a8hCCD_O4j$DckG14wRX@(3X?z7-sw}YY-t<_DiqWe%d~nZ9a61Sy+!xd z(YGbW7}yIPD>YkckVSv+A!}W!1N1)myfd@$r$%-(mI6_u#pD8<{}-yJ7E+}hy#b!U zLtkb;Ln1Byo3w{$$#CHigj7D+<$mSzU#pMZji(hJOcC2GwBL;KSA^EPfF1m;6IK{2 zJ{u%-6HXWBd&BW(`6MiaBaTCHJZ0<|7;9bc3ZOa&*3WJ-Tx+2lk>EP_Rb5O%gpr?o zzXSgOpRa~}MejL4q%g2sdSw#YJ|eilY$}7;)^bjj*unrqt#d{GMBT%gT`n#$UNW^3 z1WMKLo~IGRTi!H{8Sj`!f=0F~9>r^r9?Y#N#*8CG!TKlpAJgNr z{k+L_R6|*^$*NEeAzKdq0epQuU0XJ)ntQs*-<NcnXm9ocqnTpm`pBAV|7q6VjKj=AN*YNZ})x&v@PBsW{}`yJXT#IQ{{ zEsq`V0J4Sj1N_vytfF=g+|)Sj@AEfS@;}41>NcxCo=`)j%h6-gogsOvA?xnaIKe8m zLef#c01g2_r%6r}JV?h12%s_krBjT<)|_IUzk+wSc!51X=1 z<@fxklcgoEv@>@QQ+M4!6NW*veO?|v@%F|6dCSf1@?9Aa)NygEsJXsk-$#!6J`K&? zesA=;@*bGTG6L*RpLpmuoSi|4 zhX}S$r9guQOWtj`Dtj;Kgd+xzrnqGk4UGZ3>_B5zv9go@whCT}XW;`+19AOnZ_>53 zWc2whvLEGuN$NB_!jXIYOXE!(!ulR(@o>#l<4v~&u3;t~inKx=!c1=N^20V)ZTjT? z%lS`tST*2&jJicLU%jmlE5TJ|`nOFflka@YD(oOCgp8feHQj6r0de226%W6HO6rvW z65Em~zmHKCZU<#**`th~DR;psG%Fg&O7SJx<-`)@dF-S`S5WJ2m5l_{eiFpw)TqPTyrok&Lh;I*Lhx`@_#(>AWtyku@*`Gbm|5hEJtjHayOfVNSvl@?Lo#?cTvvqCNt= zzuiW}LPNUUY;we2rM?|y80@UDNFMF2X^NHk+TK`gE;2J5dVC99@C!LWveRA&n2 zCJ`qCx$~Cu$CJ@P3)AvcpBLMISF#PkrpHv!{FF1r3ne*V3;kEKL3*yCA^%)7oG4i0 z5EP;el^bHjNX?r=YAv7IDN6;DCQ?bnu{7`jc-*-X0I=^fSswo0bO5B48oRN3*%4`8 zdAd5rPE#ar&C zI4Td4WE8OXMoWbloq*Mz(2c#C2;G^^xXd@Ymxj!9e$OwFae!1&BK}en=F!@mS}+mC zpHlQtcOnNdUrsgbkE_C=f7x`P&)U9V-u++c2JbDFE&adIc>LGdV1l2<2RPGF-wY=B zf5C39>5h^9Md!_+-75p(;hm@`Jz~PcwMZ+HgHAC7|E|CFR^l-wrhXrRwpG^kCEKQCw`QgY3KZ>`BNVudP<0rAFO)-(QVD zsrO2h!>2(r@oBeyk}tpW*-FhB3%G?UG2nzOFzqbV)N1HZ&EY$lS-f-hpJWP_P{M$9 zlv{9`__)_F(;#gdUPUWrXFCldm%c5|S@84qO4Uy*Gn9wmu8o=YUEovz36&FWUvhDb z=%Z8U=&cjnd3|Q;bv;lpKAwkqbcEIN4@`&n7%F@uVZvOPZqRrVdZYYr8@S92wAw?< z2Rh}KPj`Ax_@oF4t>GK~Ix?pLQ73{iGz^-v?~xZ@&gSI8)Sr`E?VvP(aNt%WPYHEbeXLU^KrxSs}+iwY6f&0h7V>XI12p zfQmh2U7LufZWW84SK@I;d@qx?oN{Xl205;`)USm~QD@qb+p@Z+wFtz$IqCT6Vg|VR zMD3=^XRE-J6$|6pV^opg!5@cC1D_fj9u2U0VwGlgAFI%XwiVQ;e|Y0j(2{0?;^XW1 zO~w1H>87eO82le8V$NqZn%QjuWwDVG(`$?%A%+yGN;c6lzQs+?7qkYQEyPRrCX%h` zr$W7gLym{LABFn{UHrL_s7TIzlDyZ0VF@i`Q(@I;e{kG1Jce;zgBzao)(Lo@QTr>X zT@N>K6e?=;T&;-~X)s)#aU&;@v3gn8ZOc2tT4kl%w`SSSuT@IV=)6NK@gj@7J`&MNhRJ3H@n0=-uVtTAocaxW)DXVo<$kaoO?R zX14c>>LF_^UTaZ9x4Q|{S5w(}dD!MZB%+)tQEwcaLWjEkK*pA1 zzCq29cD_^!SZlF&?preX{RUeo<+^sB7bKLX_OS^eZD4e$ra2zr+YIe?C>yB@nj!&T z%4rj~gZg#C^$_c>Hvw1t5^0~dt=71YP0=d${+@yo|JDZ$E4>4+>YTcwE8f=<7``t^ z;t#X&_(~aGmkiQGe)vb5vA%;=&_8361uvG$fg(Rq#FC+x6-8ax#fUUBA*B zgf&q4C*>>G1(hdBQvJ$$M9Qqvb;J_DVz*~R^bC#?jrVKO`}LLmcJv3S<=1e3md!78 z{NsII^k;d|^U$wBDU#GV9f2)gJP8lmmZCT^M0Q(Ub^9;GCxMEG{Y67R4IGXd*xMK3Aa^2Yn3V)?)D(f?blfU5WjF(^gRM%mf8AVtVVUVN4U>g@C zZYQO&{|yQ2H}0BwCs9NoOcFLF($sS|tg8ZB@dl#nGhK2bS63FHEa-eK*snEEOd{`^ zxT)A0EQXVrMa$rzxnSjA@H$h1;S6YS<(oNqZpuw076|UrkG*VA=qTvZIFm9~4aWN# z1(4?cF>+irTqw-Asi$*`0llq99E8*Rj`4{m@W$z+8;{_xFw$EUqkrFhx(9AysMX#@ zL{${iC!;;{-$!eIcE-uY@aeP_+mks~f}Tr!DW5rSJ|^d3V=U#iqgJ8z zKIh~V+Zp6IR3~zEsv;T$S05CsmVL^KT2jMv-4$qxj+J*t#BCgsYkLiR4|*)N8*GO7 zMCcQ3a>!qu_zeki<5dle9G8uA(`RM#p%HN*^nlO*a3{Ur${|t>M7=CmVZ=s;4W;q! zK4X%=!8VY~v{5@vWm%tPU17#}eDb;f97uQT?bDIHH_+2AW%U@?=H;_LVj$=Ie0ukA zw)HJ7josa7I$tK*+sHe$AbwZ*qK+sVjeL*?0Ld6CPoOu{#{Zl2e44ME-t!Lip{Y8h z{1C~<^3t4b%ka|yl-_h%mhaEe{JrMgA+&=ntnc!9zUy zqz5vr5x?I|tF?jDjA<%0Op-@4O!xy~;tcDfQWiOELDif7N<^fotjZwpLa^htaz_Xp zbeF12C6eP(B)rW5li&(@tv##7*6y^{kjr(GPseefBkjPE^arw^Rkqtq*UCR!)M>lD zWGT92u-$G#u<(3&8FWtYF)qKY(O9OS}p7Ay}McLJ)ql`Hg#0z3>3mkc@vxO!2RR+?1>5ab3SGDL4SRr$vG z-y=u`qWz0whWCGh>ei~-*go@DH@MlZRV=2dEtS%P@;6%an0b%!JlSnL0bX5N0k%$? zUsm>+S7w-V@-yECziddJNsiSxHva+`F@b38R7As5$))aht<>N}W`zd5#So{H4;Nu#uU z*K8~J83^k`k<}Z9shbhJqs2Yc22I4?L;~Ns1C1f+XpnF6{e$TLo51W7i%OxgJY&*H z9cHP#l`qlIwNkYB`9kmp9tWof0^jLYN}bZc{U7zYJY-i=>zEarI(LgaHZEUIdx?2M zvxxx8t30Tsa|+o|&?|(WIapu`o}Mp1GVZ!nDBip3?JVcRRz3=_Z$j zCZ^HOVF zUC~)E2I$M-Ok4l-3C42Uo3{^OMwO^$s0_oXItS7Ft@LO>Bk3&aau*Pg=TvggxpJZS zGk}{*ePPN<;eHFJR3i}Hi=Ne>Fdj7c9N~K_6V9)S@gALfX%Uf7%UIpnS44=aIj$w66UYv*!&O46GV7*&>~2>8qz^g z86BQoa)vFMgj#?88*zk&|92=zLf8&wl%uBF0KEzWCxS6;FF&;vl}$z2D*Sfw$u82rCdw9vy_Q`7JMz2v!U%E{4tEnxz5s>pqaw$TUM+R zyvAt*g%K}7+Ec*6*dwVY9=iI785$l(k4F+ZzVDA3SZZk$%)z32#6CXc8YY^$pG=aJ z6c7L)Y(In)l<-*=Ir77Q)`}3UaFs2Wm>I6FT7f;UK25D?Qd3EH=)cdGx6e*D@elWX zAo8HU_XbHefm`0{me=dVGsE) zaGAIxs#~1SA|>=hSIsYIA&{kgoV`76;u(hjyL&qBHuQUjT*Y(8)Eo=y6m#lD0qZkO z&1%-1QbjMt4=cISb}3}B@Z)IqC?s=eLKP$O9q_{+Og6gD|2i0uBLCkl6Gj%4q;R7^jr99S$>VOb6GN&=#f zuW8Bar`%y>qq>*P_|ol^tXDJ1J}VzF&eVTSW*E$jDOc2!Gf01+fFa*OVLGTB_=HpS z$FIJ#h_{Vt1x4mlrX9l@s7eV1HVl|YNU)xBy|YTlD}7g4_On-|Z+^5`jx*dyZ4`3v z;BE8{NS&}(D8)2ZEfBDYLI#+vQ7Aw3OCoFZSE;#X4VG6~ES1!1kix#dV7NCt&JW#R zZ>+uf2Il2!yy&dmCi2q-3FU9&?%rSO-JO$mG;H!~9wxUv7?=R02}?<<4|g6wN2v#b z=}+oD9dTAlb&gx|vNj)eksis@0y8P^%zG%G>g0FsUNGK55CXvclG^cnIe`l|b`kCC zFO2vujt|UrxjHd6+a!EI+`gZ@dAGRzV%Cg1nyCg%8SKs9O8(STVqtcBYf=0U1`Umk zOXy4|2nn(N{|{UVOvxfr%l1|}-60fOL%FIG{|`X&?HQLqUQQy{y)u@7rK{98*0ubg zrcN2`7GK2VlXyy@AkFTe6=Vc0S8!2ad;aG`C>{r<=J*rNETgIV_JxhFuLSjBOnPlv zFBrSpk3j=;Z^8qe*1eU_&n>OWF!!TLPMp6e`Yoh;Vc4X$^=G~+*~P%?>jZfLK6YMu zr5MiTbn2pQ+k^LOE3F)gm#+3`yDJc`y$(yCw_5dqxhdOvOr z8@)YQ8GR%O6943k>53(ZWe8ELs{GkZPD77Lm)}{paszA25}%Juy1H!g{Z7)(dX6-E zbVp`*O97zAi;hFM@(Aw{{h)HIT_ZfaMhA-z5%1qw1mObWJRPB8YcxmZjltTmp(95b zpYm1$bVua};KSADg{wg2`HT_ z{%*^b4RxeexLqYn#p&#)6i3SaJVt3b&0jrwoMIeFUjs~cZ)A&y%eHnx^@29`yaZp@ zzgJCdF+Fa{G3y^TjpE!zIl*ucJ(VYmY`4}?WInWO%S~HMx9~Nqx@>I1|N0rHS2g~0 zVDDu}M`xuau4@1F~T=yf5Q7(v0{b-j)V;_~OUO8avv4jNg6`B`nEEeuQ+8An)D}roB4! zRG1|%W$1P@T-6mTvEh|z#px`ymCjrb)INk z^6;L`EPy-1+i7dedhW+?=tO4NBWY)c3B2*8zbkN#LmG$3=sl$eI(v^Gwbbdb$NCLd zfuc&bN^25F6zJzP!f#YNsUwT8^UYv=zjnbN$lDd;=lKTPt@m66_cS{*YkEUd{^e`H^O1ldXO3nT2gGBvMih&i-`_rQ{v8&w)%M(WXCAQ@ znybj%FWDS~t~8d0i*@`y+GV(VCbKSA>BcELpiOZ=9yS+Hv&x2*K@)t{}9O z>7WHrF3Fw1Ax1736Y#|a=auy`p0icmK{8ut`I3cnF%0MnSMEEpuUT&kQsQN>BH{t z+}?*8!2RWA^Dlm;b5B@@^<1zbe}(@p>?4-J-+XKV4^*p zcmF0oAs?rSt{i+yJ;1qf&MlsBg35QL^*P|>_&@j^OMnsHb-)e z(V)-87rD95{=z7w#6eQwks-bru_6oTg6F`gt{Og1j>|hn!}rZEY`fh4IP|u5 z_&m9Wkr#89$m30K0UvR7mM`Hv=;DEnvFMB3E@mTFuh_R|s!ZNin7A`^-0hVJ4Yy1+ zX5s;MjVlsCKSy$n(RXv!K73L^Vm~L%s;2K~zLn19Gpkf8-)zJAL2jpI6-%U3l}l*7 z=SHGV+}EZ}K+4CvZSy(2Y!mCH9|tJ}r_|sKGLMY_0b-{k_eoa+mxq~U z#+WZ$8dgJhV&%aP&FtALBU9fL3b-^5MY77JZ09&scjuqZP9+YH$3_IRR0&LYzp2#- zME+EN$&}9A0dq5i6DIPHlaz)t&~(cWiLmpKU{8tDMgpR)_x&H%@-UnfQ`!TzfQ#>G zxD>FlU8csx2ihv^Q-Pi4DHe3OCZsV8V{*t9(8j*IA%+A)!*dAAZC#yT_Gz=sLo#9W zpl&dGP<;~l9s+vKL}P4S#@|G@mob}~>cufc-gZ*TLKHAX;-iM86qMqXB~wC01?*QA zM$E_FAIL}G-wn@TDsmk)9>|c4@8DPEU!Rqvo>xLFZ{Sff%kXJFjnPCy;=Ff|}ZlwYo9!dJ%vgmk^(YJCU$-=kd zBUbYU110?9M?33lXP>>V3o;fLB}i@aP6IlH(hwNh5XqFm zK|ui@Y5olrAbtfC!{`-DPgj&2Z0U|;(2D7*qgo64#Q4GUTztAd$c_X2licas&F6u} zE80>p0>IRh`(zV8oN$HLgf6npMkPlYW)kZw-tT*%g0!A!E93h7~Ti z$`1fnBT|CZ9PB8#ynS&z*Hf#x7i8RTqn5-1TzeqH2i^9M}k*OEb3Fl!8z z;u!>c(3ZfpcX@qG54%w@`15mA+1qK03riiqprsz_X;}=W6?$F~_?G`tKlkfv0Qe#Y zSjUU`SAi^JhA#i>l`)>VpUpACzVB2M74_Q_4&QGkaAYL*<#WOLL)+x5%(C1*>-pcB` zUKJ@X5)77fkTysTeyrigvIsFq4KE`zp3GF6w%l|TdLHTcXILS*3=*X3&hgtHDH}6r z!JrDq&AeYxfGE9;mst`wnDa}Xn$7bgB*zV@6kApLJ@jeTRp|RHZg?g{)9e(73XW0A zq8%gO{fGyawRJyswtij&?*WO8WxzZsHiW25Ldbo}np&R;VvIt|Bw1@&_fpp?@Y6TK zcdLi*^Q~v1`SR0y9`|W)=iU7JP)^s`DcRSz;Y!5$x2bRIL(x0izv_9ht}VlP2fcl?UC$)Mn0 zL+fg@D5vQAxO`eAJt`w=bf!^lXteS9#U{n|t*?lZgVc9T z6*{W#5E21=5<$oLze2rMVvLvL6Oeq447d(mMhY7{bggdZFXp+#j{09;8wwqsw)I~{ERa!w zRs|>c-z-6-dVxOuX8Y2PUM8VHtwFo9L%QxI=^|II%h`w3ey;AL^^L>s(KJMF%XkZu zjJsy(=rM+g_xAJA&sCOCyx4H6`V{U^!1zu6F;Knih>z@8>tKcGLec6HhqKWAEPbLQ zlzBe+D1^;?%p^rO_}%|%?6NXiQHkS{6xmZE_8ooL_l z`*>5vZ3p{W-a*zxyGQj=_IN`{)7H>=j>Y|9z>Iq} z!vFom{)HE0=)f2@9~C<@RTY31p=q|r+`Z@k7FrU)zrD>FLE?_cZ5M^X5cU(FsA@IH(t3f31(Km|?l& zEaJwnz?i(OQC+%GGm_|z8CB&_0OQebpf9ajI^m-N#H`Vsbgcwk7NVhJ!&p4PMm6eJ zYN`5ps1oy*%lNz^ca2f&y~Y^?nW&YaR3d;nSt8Ju!CAQjI~WW~XiSjN0@y|(27Wn_ z0t_=NF~CO*)Q0G#UC|01vLalirU zXFtg)5*zG3r!zmZR;PN(F1X?r*Y$e-zQsN8HBPt5Pf_`$MkB5kAbpa^_J+&oGWxHM zxr|~eq54ZN_i5)q)6wchBF3ieDOWa&b%bT*;1@yp_;`3$-n$rR zIsbTYDC-=%dOzF4pIgEhtNb~pzm92o+}b;{-!A6!#;?LRyQ7|O+d(j`%A&S%leu3h zS>a*Rdr52x2AcMo%=UL6fDIY)eV3~(F7dW$f;H;{zES9@`7`*ZL z(M}H0{QU5-)lD{1LTJoJiMrEJdQ2!TZ&L_`&AXm(%|la5*&C+}+JFR`_$R9}0T%W8 z1d&5Pu!_dJU$-h6O_M8tBmGg8fJR?44y?#8J)8FOi?43Io>T%p4rNzg`i7&YVX@x( z_NECvy5lLW`h>#J(TV!lax{{XUu#+Z0XhdN+HdNx_VEm}%IQA(sy<98_)EC!ifR>K zEA@9s0j`<2%N$o3B<5|MviJxDYe?iv-(SQh1KqtzGG)qP7)lkwj5H|=-LEt?`R5Z9 zv8z@T`6~7mzbQJQ9a!;{Og8r=-6X7;FU+2zKOLUm>jm2GvWiA-G@69RL=+4xC8hQMb?i8#rqHQc zmWohhL$OrtU46ZKRP=wkpUBF+qq>YM*>3m7nhrI%{y4U|a$YB8p86pUy_?`$Vjr^6 z#pB>t3FXaw@6Vir>1HcrCbDMT%Tkvu_m1|4&t-E;g9 znacFfCG`MZ9d+HC6vxkdLy(9-B25=`Ac*zflEEks_AJ(Qa=N9$c`%=Wj``1_^cX0P z94g^Wlq~^Yng3mf8Q$yVw569%j^R6ca?*#Ge^(@-@%4 zka42@0#DaCM*Pou?RT);C8cDC_01)|9qPVwIj1>-75dl=Kc)ku5TnV}t3B;jOm#$Y zLgC;Vh~f{ZCf>TFwH7%CqjY%!X!Ej!&bk%+hz{aBZ7GJ&TF{@DN*E(FCjLlzXoRk6 z8%-^)8AVJl{JW%@1r zy+@Lngz|pc0mD$aUeVz7&;!=51iTcu2_fhs*5QN2tfKt(nDP$vv`>*_H3+5fgf(nt zWZ+>2A|R>+GeiGNT|)7Tdh@@Zzj)09aT1Hdu)PQXTy6hB!HbUs{8_^|>d$;OncJ98 zRK1eSVzFfNZ)66W<9_*bc&fsl+}0NwONjUmF?)sWwy4=wOI!|Rqd^=18iy(z1{m&^ zzi+5V(g>D|K(%eT6zzHJS6CQqHq)>kKS^xrE>e{T(Qr zA_{tk+;Y3;owA}>Ea=Dm2RF8&OEDgag?(vrVW`N+E@$``3`Q8H%b-f&_TOaM82yHo z?+X*Q;OP?U3f7q7 z^QM8VkVR_W{i&1czc7d7pKeGiROV3Ja0>Qm*bITZ!(XAHbzb;?wo9f~*Js_IREnCsQLR0-FC zo8sG1?m}!*Jkr@I3BKcg*64m$spQJQF2bHYZLw8$6g*y7czRcEbg-vqnoVf~%w9na z`7q}52#$4i#`N2KgX{GjlLnF=T1ITj5_w9U5HSzyT=)Cd^fm!5f=>C`+M#gCrc0ev zRDa5x{-!F(*w<1O23;Y&9n+CNOE7bfXXRP zRpT@Obp6Gt4m+5FqlAYokgvAES)0j3jGFHcdcG10`BdfQ+VR(_Tj*kkpeW|HJRSflF07L&Hth{RnQoF2>2R=7VtoJsyvAHJ8UO7;%T zjPBhxxq@D0EU4l(1g?d$dVV1G$yuc~vYI)2nIihiJODCYnM${mh+Y$ zG7mVsIgEs7kjzb|F*2o>!+)97rMut9{W=Sa5vNgT!G#~&t&i8!ijro#<>*Bn3~;nK(jg;z0?w6|AL#buhC?Aw3`3^E zvhU*z$hx>fC-I`DvqI0dcIO$%n%W#U-PXu$`R!X)>(4q$I-TMeQJnXewcB;u#e{~{a3Mi&6R9#% zdu56Sr`60xWko{Nk|&z-_!4=sr{EJO&?ro6;)>bfWQ zv=pPVbT#j65W-=g77u_As09H6j+w!U2n`i~O`)I=sb(bT_C8`y4a|&C^R5S?s3rtk z;LgofO_ZLTS<#h@iyLhz>T`or)c1ExLCjZW{f*+(84egm5Q#){pBt4|`a1BHe*wJ049{>bhW{7xj zl=CD(IkWPN^Ak^QmQ?!l?dX}-V$CE%0_6vt;}1~!tXUlnRkiQ5*NMKa{G?kipl^P_ zi{OY!HqVa~g<~icTn9W)-pQXuI}lLxprD5CxKaBPS4M?~Uq!Zh{(G#W$`@-Cf!N$u z2}?!PjrHpsYgAI6>;>G1OWc)rnO}zWQ|=j2@2j7HGZXl4e?9~J`uH!n9cdsGFoZ<@ z3{KjSPq{E~07<9IsMLo%qSdW-tdbfdiri}L3qB!Y6RZNsiHe-amMq$0rmC=5A^^fj zGT@`zI&PFpSn^MTqBoi>RE6#^UV#LeWQrMo!sws`6UHlW=rZTDsnfsO7QbtK+R8=S z&j$Eba*<%S4K;n><7|kr)HIZndtzOc<;Kc3OqSZq-(W|G)Raa-Fp^XGo5tL(hpi^nF=P4dbGjadnstidcHH*MXIs7twx~0SOjb=?if2 zioi+x8->|)Q?D3K6nCJ<5-k9x1;>y7u2i5eoSZ@?y&wn3CyF?$oS0fYVXay(R2*E{ zqtL`~rf};;)U&I)c-1BDEFr+e?MD`*L4i87{wA8BouR3sSUTLFT#O}k)Kl3(`&fRL zX*`xbB~Ou0x8$jroKsu4NhO9I^5iaok+!ag$}hM%332~Yy2{H~uTUXm2C-zS zTU!N2RxJBQcJC`N8{Z&0)tXE;g_E6FUzC{|rrEH^cOU1tt7`ZVcznuQ(i0joT5=xQ zDAkqwR@;-scdHpV;^=T^n!4|WaF*A@SANo+a43^1O*hf++W^@_G@UG!(j$FRKWj-0 zfE)L=magnpb!y(|qrIr9H3p47&6!waSgJpJ9&p~Hbeb;rotk{)_OXFa;iLKP5t#~S z6UK$mTC^5(@bHP&Y-+rudj7lFO52@d{30=HKc5&!{gKc=ND2*|S|-r!Dwg`5;DfP0 zyK_98ya!gNG!Bh?j!G7DkM6nBW-8sr_siyizxqqoqzrT^^0=@6`?PV6*t)X$Rd7Ld zs$Q5c45Fw-b@QIV8Cn_Z`)i$d=zVDW`EghVi5!6)hs5{!D4iQM$=0^OnK6pS z#fG{_uL=J&_7kLEpAadz;?n}FL0amm1HDq={rp^X{1RRUStgB922Irc3nS{$E#I4r z?hRVt|Nr65!c@>G7c!{~pC5!NwQ{EP-*XPUdfapY3tmyuXyGp6ZbFky+*8q9JT#N$ zI{o}h(eOPrv%gZVz5fp${5FTIx7#4k!-A~39g@q|%f`vfGklZ78k-T5VvnBm_1!kX z&C+z_7w_8}gFV}OM&xnUl?C2fK|p@mz9S43#pa%DzCvCk%}L&hKV0mpS+x_);shkv z-y-sZ;0ifojNrBY+Tl9w%ITG>okB6*`?6nb2_qXa8IVbRx4F(va9$*TkmH@~Dc!Dp zHzphuR}U!0)V`oY7+$}2@)nF?A`?iY+OW!nHGUC2MXbm@QGeNEel0FlQn#~8tpu>q zNP_nr(s9}nE};6$=H0gp{J_puv^S=nZx&Iv5W^GTI(9MAg1ZDvn0ngRbG+B^T|c7N zE0T>2bPI~I2_&aG;%gYu|KL7Sc2uH>1?Sp_my?1_n-CS!3MU>IQ=`9+A@^A{gD3)< zgMJARY;B|JY*Rs1t2u&p+y+QShQSqtrS_>i>+;K#ypl08t#()$ zbZ_G;_po-nT?M?EY5p?>YZj1@b5t@YA>gjr5o;JoZya4U>aq?-V@dIzK2|D)8#Ry~ z?fB#RGcTnw2j#Zc&s-v$EybvLVJOE#SE1O+VY3{a!?swCOmJUm-XS3gFUS?`t~yWV zR9SF#x4+Xq@;%-C1uJcRX^Y%c)R0LvJFbMl*sxV{pUiT+==U4)ku|i0Yp4zp9TcWu%%t8Y9jX!vA|BiL>?NDgId#*hifwDb!CcmQf+@WK%r)!^?q(cWGD zXEc3riVN20U8W`XF=Ab=$a~AWejW^hV9F2sAQP;%^inllAqJyqu_SpBJlXpfW-A*j z3yoc^TZ-zp0Nm8_l6;lnoS;K zNobo!zulzo+F+lz%Jt4qC+`)$QBP81P{K1ItWKzp#ckpI>DaoMnX%Nw692nBUjAB7g31Dcl?**5n-D*3&-}@}pn+`L~@ak?2OIL%irQS%3knBk;SC!6XjvyZJ zaGmpJizuV4l*y2jUgZU|qKMPETg7rdZ+>G`TH&U}hIssSB}St0vmBa2OiqqEkqI56 zT7)!}6TAupd2srVf`k~lQkC>VO(T&tM~=t|6WgXT5+KF(v`^b@2@GWsyblBJ!%8av z=(YKJyT@U)s0)seNT#M|b9yECq%&`D31yWJf~JzX5uLEbvtk@OP{)RT%C~%)Y#wNC zS@zhUDsCy&&b77vW86D0UBnp}`~#DN9`RLuUc4wpQI0_Ct?B*fEY~hwGf`1&0~6hR zwpU98^=_PXQB=rdaA0sqJlEaQJN&SDQ8?Mh7!-j*LPszbgVv#K{eeCFzSXZ8h2Kwp z66R>j*EswWf6+_qWt+TZgp51#cbYmIhT1K6GNY(XTNhT|-kH-P|&P{hO*o_KVVgjqrjNTRY$@zz42p>Pmjj ze1i@ZiDjm9Ci28~2hSpExLrR>aR^Rj_A76-sZBze})p)BepWwJ}!@wk<9EF})+{a}m6lMfJI^&_W?AQSg&hOZTX zt7$?$RQQV`r!9Qmzis82)|OesH2Z9vH|*#aS;4H@9pWnW@waE4&B|oXR673YtDF1! z69^uj_@OpaR;o_VK+vb6EgfE)NjMbLbv9=_^Bk$3#0b1(nT+X7Q_Wj0LYEcSB|*9} zyT5^m-$2xIL@B0eB1P-m3JZTO`2Z6B#-V9iM*H}@f&C1et)6TX!#Vr&@!Ul6dseLp z-|feKflr8J0j9l{)UhxDEb~GU&#%?nM9~VvBwg*J-+Gr)&AuOQbXN9@G~2E1EA<21 zpc63QP9VN%lhNJna-_}NWPx2tF77Gp$}x?D_s?!|T*Cy56gz##vEbe6(?SKG+Z>wC z{BW+4-|;E?ei?8mk&s=IoHNhQ}Q?`Dy-ona4J`g1pRX; zB^fcuzKP|+4vH!L6dn8t9dEKC4(ePDbe>#Y>Ee}{n8vZXhk2fW;zwCm8@WJ44BdUG zty2!e_Rs_~e4}er+SXas6UPSuY)lYK4K;eg5#g}pv#w)|o*k@i(Aj}HPx94{t(!e$ z-Vkz9j4cicS{0qLzf{=0kkU-KJb8e zYZUFl7kKdK>74JP-TVP9XpNppa1lRN@-{m2l_ zR{Kl56Rx&kv~oS%sZ8FS)ENS=aV<^5CsG*a>M7rh4yIzLDyWpJ~eswQL9Anow9j;hZM!~a)ml%dG2ujvU!|Wg{`Q1L~8&+c8mg3s??@NCY62S+1 zEuAD-3v>NtBlN9~FcY5`*|mw9q{oAhh4;fAHJKwtuAXH81~Y;Qk+;bsnFA`PNKSU^ z+Ydt!E_;lRN34Clt}#VI-;&Py`8vr!`izko&~Z(j_9(a&`3R#++%6nro%NVu;|5CF zWhs^AnzlV~u)$F|?J}X_5p5us0c# z1&@e8op747Tz9Aw=fY0KeS27Xgc)b0%zOj9w|$3ZgGHkT*EW*_I^$EOI;q*FhSzc& zSm+r$6FW{@S4k2v_xs9d+tqr>w=Co1jPTi%XFpdFRw(NPyBboMk7a^nc5^PBmB5Xs zL~UMaNFxbCKnyC>p!MD(uH5cTqxqQ3+gum^%C(!Zz;wLV60?Ce6SSu*iG@I5aI>G{ z`fw==lER;=cjBlnQP6jw>|`J_-tKWH!98GN=IIqe*HDK}#&ZzxG@D&e<@G?Wm}_y} z8k{5e{VB0ME90M4E}xjqMVp;_f?q=jNZ9#8QKZrHp7mY?oZWA!DE~Klpv#q z9-&|-|ENs9DvUYkVzBKyymH8lw7Kj$ca@O${s&6VyqS5G&tXXkj6Fs7ku+pIVT@b` z7onS);vfn&cS-fguqYD?yHhGLsN^C~Gy!O_q79%HQ(1JfrCx7{F!Plnkn!6xwaH_5 z9a$Ycgv{^j5nkF#BQ}`C6T*|^duKgC5}3LGyYbmrwI4AlYteC&bWB>x`pZ^i@AARS z3kBw>-@ls;f5FqodfP5#Ecj-#sa=D}i|;HO{F2BE0C51y1Q&s%1GInRbODWUcHmAR z9|c?F@=j*?+0?A)@?NbXK`o&`qK;sgEk_^mO5IcSqyGNpaDL>@Dhytar{gN_nSagn zDIpHVwgsil|1*T3VBdL8 zFrM85WGS@zl?tMx7^tz1sE2pVuRrEQ(q4QCybVvu zN#O0nPPzz6$q64zs8>*^UQdgRE2@L{7s;j0@`)9!d(DLlIJVva!%BR*2Yd&F#u*zWzYL7?!>o+#?B=W<0RiaLWYNv>r4wSH4>ykrh(Ef_5 z%#h0!r$)=rp)N2Xz=?o@4LW67KM6?dw9gC{i_W$=-c5zBs#Wn)F|e86GhNNlka^6I zn_R!ePx&CF9I7Ql4Sf3_Dr7YIrM2V93-1z0oxYB;19Vcs8R z6jkP``%ZF)vOWpJWn$a2wSos{&dcjMBBMM)lcOnqdj&hPuOu8N2wA)BLJFAe3sMv~ zLAImS;Z5}6w*pAf*l-j;WyC)<3~;H`2Q)8}0bhz<$yyKT$uzg=R@{zT)ks%T5&G`^$k3Z=7)=cR(}1dA*8(vy z5rY4U8bJQ=|7uvo(t}E;C+gD`sTDMu6jtPx85x*QXP->h8)0v!#^M`GUs#rpfZ=iv ztLHbx?Ah*~=08Jx@I4_7{#FPy{5bW)*&I#o79-`tk4S+x*QWRyOu@g|Lb2SG5Jp&Y zXZhJEp`+V<@W0-b+U|KWdqof;R~$h6C}C_ZJHi`T`PA?=3YJ_49KFDw{UaD4qINU+ zJ!*tIpQLr;6`7xyO)uNg>RYX)leeUaDQsC?pgTZ<%XpuFC-=G*3l=63h>*;q#ehSB z6~y2uaoy_JcNbPlxQ?)B6M=i3Df%paz5j4Ly%>6sy}osOur?!thdwP)!jGc zuWhC+xAF{FPacwG3T>b1D<8dU#hKa^Ck1T*M6!&D{OF=41;_jn>$84H!%gn2l$0S; z^O2Or0@dQceE*>!z>G|*3G9va3)f1Rp-3;cIi{DWWSw!+SEz)X(FTp$r;d&odHQQ{ zuGstA=FY9yll5D6CCcjBS6EWs+kKXhn_nG>SZ=SR7H_k;A?k$CGL^rg)S#8$HSVxZ zZ_Vtk8{FtMqMbCLE#mFkb#0)?7!XfpjlLiw^ytlZI=;@lUC1|mJ3UbnTFlNx#F9f1 z`mm*8P~}1w*?@*jjb0?x&b*1hM2Td|W10kG&Re@|`S|RpH;}=GLj+1882{skefT_4 z*8rUCYFbq%=@SkTRR22g<;Lm0wVgftIA6vsPvh&V{w`Y*)()eh{+t$Q+LpQBU#Pdg zk}oxP=GHa(psr5XVXvT}iF+%*q_&@8`?69lBvUBo-D_2=vvNu^zgVjG_s+Zpd=h4C zK5cNOM_pQt_oD}Z?4qc9iFsR2{$`}K$bJ#z>RfS1W4{xjEor8{CE1T8W@ojCE{B63 z%r`J$UA>f&e2j@;W%>ovKM+t)B!m6GU;8Xj4u3Ndz5JKnj;u^qb^d`&rTSaMlWjz9 zWum`+|J7v1)7jmT>ero`=iJf`Uv_#Z>k%7E(1$soWJubM-uOZaz9Zu;=QcC?#dpQx zGx!r@yn&Fl)4Kih=TKbUrFoX}OV*YrU!ls# z364`PfkY&^Qi!c=ANSEWo~slO8W{|vtW zWfYS9Mp-)1YM6{9$}nSfRT|a01-9 z-hxe3PkOifT^%I3$AQFiD6q8Os;yn!YJ<%Ept2hyWP3giPhly2-2DcFui0(%pOW|N z-XTO0gItr%advibXefUpQ8(oCtgiUnj5l=2Q;n;^txfZ|xYi8diYw<&(t#vgDor?7 z^*QHXiP^hs_y-}28j5C9xv8JLscl!WvR+=aO8%Ig1Ry8~eUg2He5d}MQ4$`*&E2JM z@C4*?4w9HKZ%gaZR;Vt;-twMC zsge6uM^U9;M3k73Lug$=6Es0QR z#WJym!H!!~Q`qJoN=}?0 z{-|@H?LimaVMt#o5&FDoG}WY!lBP#5DkK|*t~s05&M6HSRNh$vCf`6%!PmiwD8C^i zF{M*M6(#=w7d^-?=~7hPn%!;;*3qWPZtjpTd|EiZvF!Bj-fqkp@!2VC)^SX~*AS+u zop@VrO%MJ}GIcr62G*J<-WBCiKA()y0%)ul!(x`&RERMe^y)=k)GF1=MWrme5P3vs zUk4$+LVfeg^FUM^gxWhB)bIic?(m^(4SNax*;lU{F=QERBG8tA6?X5|a|Ngp*gw|T-e3`Pb;>`t1CD>BBxh4z zkcqZ8m=C7o5GE_2EYl_t-~Sknj$b--s*9wz{2hnLX5D4nDU4{`L`>m$9R6;q`)E5B%5}dD`vET3YO7pXj z!|0p%tv%TSqvI~sdE=!D+}jCCE)R zSP{`3j;4k(0j_1F&a5?oaE1L9?gc9tvo7Zf^jSC3=?-d*J5C=7nv0O)T=#ZamL=XT z4GK*naK(##ktxqx$9Y~vNiq8g`w8MLd0 zJ}Hf+D$5Id7b3gB{i`51Foq3yX6C9xtWgx!>iSXK0qA`5xo*8CucP%2aeh0#e0`WkJ!6e-xxuHl`s_NlYww4;?PlICHdof#)MGcGnqhH4p zZ?`TMF(qPjw<&XUnG*g?hzpg_9s(X@`d{#2Mvy}pVnaZhc#&(b-1Kiq;ysKsCXmN% zeCfdKM>s))B;Zvg1!#j{pHcFh%d{G5hrrV_U?#B%>TlDVaJSi>hvr&}s)E38qG zCj+zsq~N4;o>$Sn>LL;8Dn4b({0U2tViOg8<)SAMYiTJ&wxOIycEewP!3)*?Vsjs* zxQr`Ro;`Qmgq{~Btpg``N7kLO{C?D5QD@Ca&7IDWKiNE>=*b(Rc5Ok@=7_z+;*n-k zUMd82o-L1#G7M1~x4J2QCFCv?xt2=KSQc+p4lU#qLZtaTf>xIOs*zGm+I*>DUJqut z6x^iU?FK!aK<{Z$_eL_3&k9-A4Spv4>&pBwp=^^sD~CsEcySdvqb3c{3v-cBxJ9!BA^1c@n@^znrEPmYx0-@Q$iDG0j>y_j7dMU-wYa4WU_bp zOp?){wD`Qumfx+$M8U1eHgKUp-!+I^t#xe@w~0ccYOLpWp6g7&M3@_GIT^bdA>vdi zy*i!nhYGq14*fn~_q`aOPE3B(Mw()>eN+kxztaG2CudD#Jj+8K0h^eIk1%aPET|!= z30i0-yX>X34g}p=)2>YYp(I~e_wJiN@(53@vt`+M)Lcly?Bxq_UypAiDLrnU zs{}+gXi<*<5Vk!~kVR|-3SP+QKto^}APOArDbP{9zWOl{Jx;jI^Tci4TxmB8{a{ac=)hd)-H}Cba!Vcd3-ZJ@3v0{ znhZYb=ZF{DN>M-;fLDR|kZMg=*1xuzKot#DL5oxPlcEco3-;Xg8|>F1b*)%Rn*QT7 zi#)@o>|T%@uBX)6wN}y4PhUu8hppL2@vcZ2H^=DD&4@+h>cVecKs{U`+c;M#N*QES z^&i}dU!O&PA?O50zFxk*LJeAh1#q|!u%UoajYzD2I}hN^uLz1{h>k~l)8!fGp}KRN zSGGx$BUMFV;GNxL<3;JkdumSi(i7!w&fvn&t;aQ=ocaqXmmcq(2Sxd-dfLJPaYEWU zuyw5gg8Fyw+brr2n|a?Pr}e6c7qi{Z%?;a*Vs0`bB^@Yy`MG;?S=Z(Jz6dBHv-i-( zP^R{h#t8EP+LjFiyS43U2QtaXCy7Vr1IHB$FlzD*BqxIiOJDZ%0CuKnA(iC!JZK3t ze6Ybl;N3bV-oK4~yeJ4(>2$IuN707ozY>Q`4Wj$ZfDgA{@{lM-F z>9z5)tJ7*fc3I3FtGZ$NA|3e^j#Gc4OF!-5VV4euUD8-@%W~5Nm)Sm%iQkSb@Tp7n zMUq)pwRcGO4;08fgmeY(D;KktG9A&JH588E`-IIN562$n6WfiVsmfBb0)os~S4xVe zE%9bL1?Nqi$k9^8t}ql5amQ0{AYzNS)*mxpt-wz`JP?>HIRGU;VW9s$bz*6k*Y_Kq zgujP6+jX*ZT9U+x!anZ5XO^A@l6ALdHb*{gk2d1&7AkykPkIXUNLN_ixZCZLY4=%g zL_KcneNiEYKN1~2TBeY+U0GbAtGQ|S@VsuG(9h~`3zd4#mm5o9KjJ3a9Q40W_831= zbNB^3<+V-^d~v+6NTql@%ai=fq(Nh#qT zg}~~XE*ccQ1ZxZK!jO4tlW4KwNPt*YV1XI~7Co5K&(z&&zq|go(vn9VS&EQG%w~mG z)fc-m@2$oC+4IGW+zZl%tm}L2{&DKpPNZBS42S3Pbf?|dK)c_;Tm}9F`klqc*?zGY z?~ejhRs3~!&6lfG|LN{^S4pUH=Wn1Cp|* zAt_Wkl7+;7LK`nq@+hO3LAHI;InX4b$EYb9Hc#b9#!-M?8M(6|Pj8cmjsvf^?{^@G zI4Br8IwLz!m;+`^lqO*c10fX12vik`Pc1o|ZR;u36eq)#cbBV`mmB|yC_ip$_-6gL5{NK0?OhGW#6b?GVr;vM+4gj%E>UYz->V#RYI4=8JW}JmnZ}|~UhVPj zSXcnCt?OPkQBlMi!mQ6&LvovK>4K#k>w$g3h2u8LRql@<9AScbYy>zX9 zZf&}$ot&e5JScjul5;ZbPhw=vz z;uEurxI9C!dUa3rKsFCY@`H;vIDQ0Ia?DIfn>l_GsR~->d^9Oof3gF1iH@vcE$j*_ zW%93O^4k^jK*jAvc@^$t8%4fSa0%cqfbP%){;xA?|cva zaMB;HA(4JnyOs94txqrSzl-s&J?9P73=rG{M&R&&(k`_LkGhLUy0^bS?@RU6_#}ZE zY=ulKrn-i;WjJMNLvK%GeeLZzIU2p8k))F-4x!$`?#(CTv%}KN?oJoJzqPN~ zNktSNhV+_zh!0Hmc6zSga|FBX7md~snvfuM2K|iO_9o6*X}zu*aK&mv*uk1y+)?HI zYaXYF3dRyPU&ZA#KFQwl+CHI~9E)!0U3d=q+s)&^xV(~017$ZH!ay1>;BQ0aJaw2EK(#&>A1Y2m2isyFVG;Of7V-w{|3pZU_j0?5aRnk zn7s%EDss#^0HW(~u>@eDrzKLNdphu%ErjwiGVvf!@WMWI<~Ea$z0Zzica@*^chN!^sLx-)xz>y58$`W>DGQ{W7w~+{JFvCv2{&4 z0^^?ahR^_dX}ym>cpy&k_>StxPi-{@f)Gw*83f6=aqk*j7<56%qd65kn6_00(B zQ1Y?psrt+xP0#=Ae*MA|Um7V;aO-hzkJI!;vn-)+Pp(|3@k~6mXzN(fDl}(gHZb~B zE#5;Vx3-_4`VQEBRc&BXdWZl?<=V-Z0*8#}99IqoeSzu+8gXPafF~UYZHX5_k$^nm z*>|!|vRiSfYI7!4N~I3E^T+t?Q0Ma5+H#ZqU^&i;anb&G7xxdvPi+bA&J~mY#H_t) zaEnP(|GcNkr+)t#tlxX@>lL*8gVtEW`KwCg`-6TJ?+2%w_D;Ms_iy}LH0~wEoV{GB zT7Abd)|mmp2;)(IPzxh&ohS+`oP1is(HK_^vRk_^B~0N)gLJ^p#^HJ;obQWG5mVe# zszed8Ql5TfqZOQ^(Uk1nU;JnG;h2&C_=G?OmJxLDFEYmP96;v8ct(Yup5{~mJ=uN^ z4H;ovkLD*2)m+ZkNSJ*K(rHe4XB4r6{8M!C``K`t&yKcJgWKOB=b4d>5&= zo53EiEbF^hYqOu5FJC{E=9n6{W6w zfBO=!-=hdi1?Uv|rjK$V9U-R?W{i1Ey-OzQUByQOID{$QtcL*mZIm*i(OK}N@2I_D#K%I-SJ-b9BC#CwMK{+7eS zrp{HR+y>j_(8Sfj2pC6;>c@m+=$<{{E7AfXV4+W^34>t5vH+$_w*9%Q-7)M=x`>T# zI^{3)@b@b5A=Mw=kEMS)O@w$~1d1-RM9P&be#eYq;A&Y)mZ++oBqY8K{!bAH0`hH7 zF!S&KZJ?5cB9nYe%Xi=|c>;?afZa^3pDu2nO5G_!8vz875-f})BmFUkwz$;?6@HQM zTWENrThZq1`Jl}&$=3WyPdVfLbCG*X{EvQXv^&nh+$ zsV;@ZGP6DiD5s{-VQ#-BTT_3@DEp+GkBG~4l*g>5Ld^p15x*?8{Zu5}6?sCGpYm%H zLY;s^7<&Y+oEm_O97RvMQQ{bBzMC>RK>A^wS5ofno-E>5Sde3q)2fO=55tY4owJDf zq5UUJzanS4&zpy3*`LM$NOr@@>u!`du%u7U6`z_K;EnHtB=!9@*!jcft*DZ5^aCdU zqbz?saWF3f0D)=Sbk>s({G{gp@pKN1k#y0zj-5=5iEZ1MIFod2+qP{?oQZAQ&cq$t zw(XnmoO|y-=<2GytM*#&dLDEz8?y!I+e~V_JMrTi&-71G35xD6m|c+k5<$MgMFWHJ z_Gg$kHOUZa#P&x6>)_2D2eH@!L}$^8HbENUi&T$NTPP7Bsz>+rHJqvPdUeEh zpDgq#$mK5oTEa&`7^G?!h2$O1v>vu4Jeo(dx~~0U-vs8OE{`tG5$Mz#*!4>uS~rFg zf&Qp2A5&V*L)q0@bGo-yfE^Bj+fRGzp*%y2y!fS@Fm~w%Ph9NJR9eC~^nX8!G6uVl zKKIK4C9+HQoLWp0l|L$3she}o#4b_Hz4HR?=Kiv6u+Qn)7mGMcFp-_*d=&spN0X*%rF8%9Jxx?^hbiUJ5Ef*X) zE2-hQCk3v)fSwskYW0aq@ug|`Lct7K1O#Lz{<^TTkUy&x3NYB@Z4V1NA>ZLw_6pF_ z8ISu-eK_=5e9tOR=QYI)R)wH@mj>ZM59u?J@=R9mH=v`nw@1FrdfV-Rw7XNQ7WxxU z!>)5hyZJoG4pENiH=7%4gXaRuj$sIKVG6ff{TYDl<5T4Li!&Gzx(ty=N!D`D?Ei1*x08*7>uev zCJNo@!z6Hd%~)+-PjKREHk0`kg`m;#K`-j9%6~6+1K{BZNB$Z{8TSX)S0SdPF7^{2 zF~8%=`BuV@ARSe2fBH;gC7EoRO9Nf7)Xsi|KMOFi-Eb1|>Tblemy1mZ*1Pm?!Bv&K zlT6n%SOj0SC}p+uM10hV42>cOQ)O8Y3B z8Fnz&dUB3(K_VQ(u5T3}^-@>8SR)k>(cy zNFg7yCOG?apd-szN<{#XWchsJ0_E zkkP0(luhP%fz3N8yg8AonP4gPYKkaph?&BdlyjJ2}X*9Xr z_>9=PwBG%ZI^EaRijJGfFH)wQPg^5&n%xi@g;yIfPVml_7xM>I78?d(uo4VSow>shvETm#97HUZVOmoSYTy%>j5N>`=URnnJI zBgvuop$6z)&0UBNd3SX^uCa;omqFqA)(za3B%nRP7@-8X6FZ5?73iV|_l((Ay}64H za=H}t+vByNVH6iC5DgSjoNW<0JmcoKB#3Q5cf8`cp=bN>gVNkFdZ<>Qwz95y9Vt{F zHm%@VfovO2|vBr%4bq8k=+4zVxv2e;SURJCdC$3SfMvw zFioV>UH)=;kgm9jDd2`eAnopC&D$M(bh8psu{x0ZQ*4U{9TTEK)gqRnvYjCIS8zgP zrh*pcBkc_UJ|V@Y&!jAAxA%;bTvSM>C)Fh*h;rmlEvs2+TKwAUy7ham`PxnN zqU!#`GCymp6KoxnwG8TkW|)CL)O|OrOt(ztU+D%)_WDAPq4}O6?-<7T6J9#Sid^{~ z_QJD7y_&d$O3wuloFQ(>IB26jD@hzzx<)webbaCPOkxsypp`5#Nd z{=106^IzcNziP^V;Smw!G|}^FR?jjVyc?Cms`buP7qrOoEaVC$$=6)2(GRv5#;^JD`C=C1(#r*7`e^9t@;d8Ob8TG#NWG>!`Q0V7wK_Bar8PLPoMHi@_4)Pl{ove;szWIrP2cohS|O@P}s z1aWfK2u!s8T53aPSSst?)d3;+>dnz7%m0 zM7Hx-`4yZLbjmRpQ|{W;#no4L-83E5cMbHL1Moiwc*U18&CVh#Qu2eALeu;UOG;4R zpKV_*A8mFYUp}dJpCLD?s-PDn2AQ9~hipSMEK;)cH1vEP9}WD%!m0rr5d5+$Yu;Nq zFK&`99*V|km1u0U6a~c^%=stMEawNVV2WY4Te@-jK_8mnB)Co5u86qLz_a6Gh3Gd+ zMRVzS5}I6sh9o2b!C8ZtPrqzpO$}uxcPV&R?+6TYuCiR^u4ADQ5f*9eM4I-Q;u9dq zbHFz=Y5JQZ8U8;wUJ~R?u$x$x(xhaD1nc>cHgobHQ*PA+U0$h?4^d?=r*nJb?_X%o zc5l@a3^N5DCPyuTvOK2^fTzcd+O?5|DTWI?qhE1@8TN4FBKFJU6$mDmJ0|I>&H2k4SOGYMF5*~Qq5daPJo~F()LB;J zD7(QJS_qw}@CuhUZYd%C=zPwa&qROI#V&-33ss4!X8zyd9r=4XeBXnB@4SoXe-SF- zG|7I>;8B{>hHBHcj(id2gd*uoIv;U63gA)8K)PoSr7t01`BhPtmtD&Pbn8LG1Zhd;ENLL#Ah>S7|yK{y_Q}_*zqZU;GO= zi*gIkQ(kaSd}JOXtg51J?;z}^peE3FMku?KVKidzI?v%B$Aly|1}T3{d(U# zdwB!oE~kqfHP!OU6l!hhwsgx16@AR?`#Z!pfr``09zmCenq&fNo2M`I1&YYx^0v;D z`Bofl7QD{qyp znndcm(;wTA;K*1CaodnJ7hiye4Udi<=ghp7ASZc?-NZQ%R;K+v*;4feAP_R&$`1gCJr z|E*kfZ?lW@iMQk^hEpR;*`rS5ckt_8XD4Ht)-UH%+~dYXVx!h z6K5VF#iFAcijQBOrXnm2U=Q8c2VIKUMg6>_d-H83FywIBnTIkQ`XA?!P0AM_RZ(w`D?>R<>~DGP0{jm_@ReHr9r9KXY(i9#?kJF zvt?qpR3X7t6Uv%Py*u|ee7`iN%FkW_6&V}x=;OZM!uaoxo^tG>>+$^eTtclJ@YS{# z{hG@^>TZ`KXW>LgGHYJ-#*#AsM3Pc>H{%Isg^Gtx1Hvpdu=}_Fm*bA`pE!)~f8`ETm_NUxVc$)UPz;#pKvM2K zN5lO-%<;m~Dt77QidtRJ>E~~ggWsuutDoAZFXj8`>fNK3Y>KPu&ROZs3jKcK-T?0X z#aG829lP^~)V%X=p62isbw4eXvRQ%3W&%fGD8c$Mf-IuR4hPmpP~0(Y=T!P}x7xmU zTb<)T01QM2po-JNcQ_^NZN!u7bN!jbAA;G0s{OcZeK{rF?A^9e(R3+Dzjb3vq50NV zbMY2!w0LpR_}X(Szci^fxbb(W`gp6Zd*XLp?oLnd>urFUh)xpq`_jIJ4)Xt@lLLq% zqDk2AJchkhbr`#(=g2L~#S>0pp?eq{*ACBzhp{I8WZr5TWLD2tP%Qk89_5VI_>wQH zvgJ&EsJUu#Pw{=)*;d`4IxLo9y`wS-h6Olz2HqA_WE_1e1Vhw19b;>XC=F>~C^%Qr zZYj(RWN}Yf^1Y6s$({M7}M%ENKZZ-#Qn%FQ|;G0;3lA| zl8hXZ6vtqc?g9iy-stTLQM(V(aV5gQpoYYLw>SS%eJ}E>0a~3C7g}#)UBwx)Kar`q zIh5%?Zf7`G$xkR-9#;xV`us}1IyRM$SBlk;otLP5ClGspD?S3K!Hrt|Nx-*YcSKV_ z>zi|m5Kf@mi|0zoDcJ2o4FNX&k+L$uvD{f5rBNYozui%LsnwP4t6qE_-2P-2c^x|C z{oTCT88e|&5&t@?@8QU&zS`GjrBi{Tq3YoVcvGIqiKy^y$f53gpXK`Z8gKr5`}*>g z&()^mgva^s#hpZ@2p24G;9hYRka*tWoj9GM2eRG=lhjOdin!9m4V8o* zaSF# zLG%_+WxatM$(pDMr_yEK#>vh<#)qCYyLyVeHZT3oOpaNNUikTwERQ%}!UWpW=4Kf? zkY2hW@2w6_y~%ZzX>=Mnu6m6HTsFpJ47XZ*8ycp@(GAigN*{m42LMdu&6LWzlT<~O zioEorf8&nD!|Q7lqAcetlM2m_+TAdbA$;p>eFbI&zin)hW)2n|QnrFJB4+RmFYr;~ z+(yz>G%_H~zNys{#n#j$9B>b9> z*SjfWA@9LcZo#m|Qbf6m*VI6YD#jkp1*|;9^W(ba-Dup0WK68~%UN)0gz@pm3~h%;}X#%vl`a{+x$f9gz>3W%GhqK>vhY>b4k-ZRfA;%h*CfaJm}fo2-q?Nh zNSvu)`_627cGT0O*=MT+?I8sK&m-k(%39Nv3yaunAJ4>_2KT0;7fpTgqsh6iG^^oS zQ&A_w;h56J?Djq+*c$6(NJUJ%Zu%?p4eIw#&_@e3|DZJQKCEr+b zsZEhsJhITTDbdVF0Jm3@2)@MlEDMG-vw8T8>czwN204-Ziw#C2SGNdb5EWk>_Eo7= zyE4TfzZ=d@2TtR^he@xpycFyHyGj&It&3G;Xxd-(%;4^ z6sw}*fcq^Es|o49Z5%dZF$Rf3hEfAUwyqp$*zE1N6Qn{ZZ4uKA)Fk<9Mgtes!;ht#yCs$t%8W8(L~!tZ0ZHTh)VPCK;-aje0M5f#-rlul9) z`ub1vHs!{DAu$`aZH48GJd|@qp+2IEkS(Jh}K2sFUHg+0XXWMzMFM@)^x`B@5Vl6uxAHu3leal$L?iq z#%{bMNslIHH#`@-v?2DD;}ut8vKJ|8h7-iuKb^Q10I0@M5NMg6D54 zRa^PMUg(Sr2XaYB#bIgaRoOLa+Yw8J(#H?RH}RFZ9({Xg+%|ZM57Axw4Qe4rxYxUa zjy0=M!thz$=}8H#Wv`mYnrS=P>1Iauo`hI=SYJPxddSW(n*uwx18^HQk$YUlt3dQI z17@{?CeNB@R(2z&V< z=XJznnXh&ey~Fc6E;xd6Z#L2S#O;&m2`+xK>ow)0DM18J4?3wyoo~ZP5HOb{hK)o) zyovq8x!q7O#8@j*s(7*^B8!+n+NG%}SlEEDPU!Gk7yIc-!NG*yOq!iE&i#~>X5TM* zxn*k=E!nCM)S6Ih%NR+rpI-C+Q^nv?2))mY;Ybui(bj%x=J|sZ`*%z2EGZX#p>XiJ zqF`(Gk;0c_&%aG<5!(chEkuD=jG-efAZZGwL9~GRCN1kp2tZ5fGE!C%Po0vLGzy0f z`O2rm{un9f^=~5BGQrtLHe*P!hyG-Bb_E}xF8Noxr4}N)`4o#)hY%2oL~#qw8*yw` zqN-$-X5(mcx!m+>sz)y*c+Aw8%kZ&7@@8&r_Knis;!5V!80s1szb+#2039f}u-vg0 zIM>k)=RM7Vj@O-(*(xARWi4OrfRAs;F(+|t_V3~Mytc-)7LPSv`rwzBq{6Km5; z=@kJ(*^W|eFR64VfXrWHEh)@bouk;}5slo|C(Kp`9-<6jnsc?Uy=o8V-L>s^M?cS-6P_W+(=D;jr*$JPQ&E4M2gwQsY|4F zQcE~7&`e@HMw;YzwzaXK_i;-7`XY6bGE*0tpSLZzhV*I}jU-#o^zSmzHdnC87L8*E zWl_-$@Suwf`ce`>(@guETj{L}w*xn8r1n5#w3^Lo55L$=@}7avO5g(|8js+y)xSk{fkx>?9r`;d^%jZk77Z(2Gg<|7?b@HX!KCJxPuHiLCFPK$+XD9-=r7zt}gezz$Q;uemhHk2ft$9BQf2j zw16Uoe%f4E^L#5xCb}$b<3&J-a|vGJ#U%=~bT_APgB*$# zWfHN_zO7})5PjsnKt#;Zy2$5#}|F_F?0pd>V><1y<$RsrJLRwUQl*+Qgb5+D40 z&${Wj-8~swK%xRxSSoW{TM+B)3JMV}uV#W#5VJ&)cuEyu;fx1mAEmA5GK>~@P-_Dv zdOSGzuXWr=&_66b8+!&=i1}}fUJDR3cT8=uRXcp1#m7Ugr}hBla$Q)_96Shn1%dHt zFHi5tm1cVdF%1K-!suRuwkta`V+M<=F3&b8C`syKH_L&LD9h)YFv5`+kaTE7pFX!- zpKamyJrpI;Qd zC_u*5e{Bk)hZR{H#Fk0CX;86FAQA6fG|ktWDtT_q}>Sa<72v9u;~XC8Wp z8pp;!$To*MzU47si(+p3&o7p60wN;ezLn8wi%$@8N@_#m2aIPiX zVmE==wTyHzMk#7R*16JENp>!AfdV6T^=*J{)Eno~%g`B(@A*e`WTyWjfrI}uMFo!! z)hGNXSQLe(Ey6=l)v~FvT{_BSR85LJE`Q_1FXVMfjAv`GOWoC@F@v+JMWe9Yy`&yd zf-$GaxM9pM|E=?lGNbxMDSFXiXT|EKZ-OG#TgF@z=X3jAL{~UOUNHX?RBy0wj!-PF z;o%GrwlQE0ele1lj}#=-+~UQ1n{N~#)F_M_#3q@j%U}_qdjfqi{vvrPu!z>{acGu& zoLOfz0nQD|$Yb4;kUZEvNX#>9!in5f;dJ0E6imgqb=CEC9W4fr-2a`O$d$xoK@9${ zyef`D>!KEql6rPCq9SWtjhVc7AcJ{zfx{1Y_@d`A8gS{@|75t2B}R5mT*#k-^{91q z@ZEYFI6VGTdSrYO$(76NfyjL^VQM+;GcOdbnzD4VMI^O&NAYnOf%n^4@bgvOeFw;i zvzg5ji%wjfW1rCeL6ffI38!vOk7Wt(Xb(rDa_g=6wQ4jBfp9b{W!y$H2K}R57Xp!_ z5nb&OB05Ppk}4PsHZZ_MNc|WS4pt-xIahQW!}v&RE8BSdD7q~nU1i>aW=Z%OFy+=< zoD+0<_H_BS(k?ff+OeYZiQ~3WlFZk#IJJ2vqqKE|G!O(`%G0L1){GM~E3M6|Cm58HS+U@$Vg`4!d}(Q%n}Kulp|28K zPwGyu!t5|d!>WGTb*bzTt!Z*b(G>WgA*OLhg?KF8bO=3rVD7T9zYLIgg(!Kj-6JETjZw;$G14!@QyN6G5QanC!F3)}2{rKlDO4RH^ z_?w;qfFDC^>DGyUrcts$n~k0K7Qr)D1#ZhuzAp#-ntr;M&4;NDTc5J%bG_^*ut;>a zB+c_+frW`q-I${|)clvKhQ>S-?wpf_sz=MlARb+V(Pw?ibb3!V{*TP`qP6C0 zb2cUXR=ul9c==1bzjw82?0VKuVevrFp@({Y@aS0Z(bH$R|LnMXpd_-j`&gfST|PRD z_pp(>>1$2kpk+EJ_x%#(A0F{O`H4=6W{=h(8#DbXyKOAGO-Rr3?GS{{J`5>$c7$G` z!W}Gmd1^ui#ROGXJ+_%e0IWRV^6R5#2jRPz1Q{8qKdt|bK!*$t{ys!gp~B?$6PxP( z&h~j$8os+C4w?TuSfRoG9V)_=#aru-+c{6ac;Bn9Qq~9~oA%^=iB(iWDiU}`GSmCj z{~=1?Bf^S(A$G!MS=no(tg2OVuiK(T_{zBE7jye*#w0k=;U}Dq|JY6i1=`;bEOVx!Ww&h7WG3pN~k>US+;+!E-F+{DzuV^+@GgftLfq zQrv1$lRjGZO4)Re@-{4u+dnIpciGtf{0^MQV?GAl&z8+&4@|uq(x#J|i9u!yz+5sp zaeWvBYU@dV*k-!Qs0Ay_Wk=A7C#Tw1?4rwr2mQsdpoQZXiY^uW>iL-Y1A|E#0yZ4L zj7%p=6&y&+46749-ZhY2FFDd)TqM&Dq}X22wgqT@_};%_EjFCq{j!har0HOn^)S1i zq`mHNf7NTcw4sBv%`QE>9n&1hY}IP!h*^->=humu?Rv(t@hU7nh0E7{q&DrlcB%LW zyPj>?g_WlcOxPvyKlY<|RSTRmr8)0L*Kd5D_f-qIrxAtYucUvB>wk`?9a3SIJ0Nu~ zZ7=c5*STDimdHmn(e@h;x=v#(r+981_U^j~3^4jmVSl?n6TaIfN#Bo7-V|pt`^4Y! zkz@=5S_S(u9f+Ohn$gLVovW*xH?OzOS1u3x9xJ~I{-e#>!O4#f9MAKb`$@#_Br6Y{ zg~UM3sH%s6l@7+}cKOEmR{ubSQmR|?hto4ZjDc_Wxp|DT+pN=^WmmxUyWq_Jv!2PK ze`67%37FqB@0}#~D?WOI z)8HzZO2!R-5};N}Z5%=-kpdJ#uKtLSKdC>+d+KMjxOsR2nNHA!X%u8fvH6p5l1ffB zh?J*9qMgC;P-Alb_SIdr!8r}{uwG%)Bbtb70m6ypqVuU;d-G$@P`>Rc603ng@Si#@ z0VU+tyO+e~#4N&Q-yS4K0)j~n1IC%dh07GjhQ6*DRhclle52h2kP9wfu8}#Ln?NrB zob$Mnl$YoPv_ezuz~N7hA^OzSR!@HcVk8NPa>j1aX5q1P z=`WeRH3L9=CE}nWfP{v9?y+;eF3d=_&D#?}n~fB7n=Q8rKvVNdI{Yf(@T{F~AxlqE zGzri!a`>aD%yw)$A4{f0mXuc4h1=92j9p6Wo}*I?iPxy;UmAgg>c3GN8ix+{Q@k!) zjTU0pKOZR-rOt7D&FvKZ6`5#bj4y z##MAJbhv%Z@G%jSlW5hke$8xcfhb( zkhsILh&!t5N)G>z#}Zs-xmcgy(F?QnW%9+Ft3l139uik*hE&)|BIVmxNP%lCvm8$~&EtE!wrHQXa5t`v6`$s0oZP3p zxs!X4CMBnd$1*h0%Rc!fJ21T?NsHN^ObT_fO1WrWYO;(nuxtOa;Ln&js`AJmFtet1 z8Bu#_{C+rhPE_ zjryI0{G{b5ug)g*KLk3@MLiny8b`>w7$?=5!%A5WK~-4du1~n+eg*3*MJJCPr5~A> z0e5-zRgaoYKeAMKve7F6uvu%|EdOst5(-T?o#Ou{9qsBfO=%yfSB&R&x?}R~SdDBK zVT0z;TPKgKXdrmeSL9A2E0}zOJ(+A7;Jql?`6*0`o- zcrN?-B2J*Wc0j1uU?tIaa8`d|ofRt{f$_0Iz%0=K%*HRx{2X)I2g@unPFMqWM$Zqy z91gdjm`Q*W%*_$Tn8l$nOjx+^iDTs=>GuwG7*Z?PM%ZH0%2`*yzyY^(5S?WZ#HmoQ zZ0ZgX{e4G_1u0qb>295I3)$YZ55!#YoAL?I3n{whwCsb z#*z!1XAxwn9G(Omp|ZWSccvs`M%&w!ALJoT`6Cf5klI53o$N&(`*rlQN^g!e`=?za z@8S$jLqnYW=5HqaWlz5yj*t1MrDSmI*4AmYt6DQX)o_Gi?3e!6MWim&CZV3^ zn0!zNI-v5FR_9rtPd3G}OZa8KFPa>A7r5&}l&}9fF92&+YkX>v1Xf&j#sGuD=$3C$ z-@j?b-NzU4{Ozdi=B1o*4fzL^sBHPO2bsI|ZE@0kQWO+`D&-ys9r$nlFmPjT4^sOh z+yy5V9W~*=Z_DXeMdDP?!`W0~?ws(!h}$-60C)LH<<-S66{Mz-%B2*1!%pQ) zBt2Z4*3UNe{zoZ%7?XhmlHjIqBJ*kfkS(SnP#H}qf!}VG69$)B8CN!p_|PC9x5S0N z=$xw$iaW}+Nhi&cQc;~Oo_^0NfvZ_k(Sy!o+1b%T#J4LHF0Qg_Z)&KKyAxD3O&&*o zXyjUUy%Q}zi4wk+qBOjA4=Kil=NF@h3t%}G6iR_cmdf*_XXF7Iw-*5>ve!7;=38+k z963}ZFUgb5+Ytic}(rHpk{s@B7Vkged?i1zq}||C=Qy!6jz6e z%E7M%@hdyrNtDA@lfdixexU}ogpj}N2heIIRS#}y-*{tbg23Pv=Dm@=ngkQjjojhP zN#UFS7&nWS1{cR|VrLOZd5L>K^*yc49;NE`6suFofa6MC8Q%sQ{NwEUsaWB z;i6LFF-I$@GiflYvP`mC3({m?zb-9S8b^R={z{ePcOGicylRvL^CPNEPFmz(ZynowKRvjI@YM6|DU646!ehlZag)wn7TOO2PTqQ$SGv0s zFemjhEsU};KM(KvYDu%z`R8RV?}$W);UzIG!%D$D4Ma%|m$YgOg!{!BCt32~gNFmb zRrg7QYf;j&S*_N}cFSx}P(R){kd7Miyzu9{=)KZZNkIIPvjoH4s!l*f`Tj# zy|r)sCLPqDw)|i>eE_&>5)lFN`VG~`rqgc_F|I->14VmnP1gM&*mRp}tv<7{Y)r|h zhQpj-)U@Gn!!_6j<6o+A>RX3q>uJVZvQJ z;&S?fQ@A=Iq6ZuWY=@n=A&CKLK?H@=a|XT!-NwcAibXa4fYYD_sd);3KqQ@rSY0tM5RxD4vvL zr8eH59Jwb|`W0)+%1y)3tS0f<6Zp%EiW(vRsI~ZI7(PPEu11ktq34H18s8?7W0!>Y zn-MW!Jj#Y78Ns`Zs)vlbvsZyfkz^70Pa90iG~b<{kia$OEYlFu7QH)EyIwfqPIQxlFevx{q* zycP$Ee55lKBy2U8+AmN^m`obPB<1O4T(F#~Scuju%_Yep{9;24x+6~nU-0nY`fPZ+ z@DqyIr$UXh*;l!zCNFf@*qPOyd4a`l4i(*lnH|bbG=zunX10BLA#aDeV@+ung9fNo z)zc7e{<>`)LnS@|7|8I}=YsW$!b@L+?R;}>_y z4p$FDJSb7pr+In$+tub#_yt=Vjtq--Dn3DX-pV zTnMX3woU%Y!au#hd6_CIn06pvpUPF@fW?^V)UZ`EFZ-he)s=#C z`4y?+ipH2E8?|TnhQ@;|98Q$Ymf=w$uHz=HeiXhp9WE85g!dxsLQi>5IM51*Q4j~y7 zH@*W|{gjBt;UQz1zc1nEeq#*_dF{>47v3%% zJ*0^?N?Ygq-VTWERS0tK_S6H|rFU~YyLxp7-Oei-N$@`w2@_4*i9&gd^Ak=PuU6VI zii?$rA?qu^z;gwvW%5oc`YTFcB4-!w%`ehaULr#nZr3)@q!*!Uq53aEp%?y6lxtgu zWm-amPe)DlY9xhtr0TOAVpty-BshK&g@9s~e}Rj}WQvaft862i9r6ps**|yBKV2#b zDZF4?1u>C{nrd7F`xq<6HnQ9EZ$9jOt4uTC;;iM}Fyk4{qe1}H!^MAf3 zM%0}3mZOAt@?-6A2-*^8d-{|!QuGP0x3r6lJ1J#ugtOVn>9sXI?R?^_9+n;rY1XI;_Kg{RhknvTz`EJNbITy)!6VjKbv0=!k$vPEEI z($;G*)1PxSPQo!;aq?9jDS5D)Bhf_2$^u2CO>kBY~s51So% zFYrMG{cD#33G>BU#-6HSs(sPP^t-kXP|>P1!qgZ&IB-J^e)2F8bHW+xg zu+?oZKvg_%%g7DI`|KlV%mB8mtCt6C) z3XuF|xIM8C!xOL&vFDEB6#@8|gM2FG#zH>S=TK)*wtHxufWg1{2F3!c`y}3|lPLWf z6bf_~yUmln@fgP-3COcD(C$H7BxnCji@8uwFqlgdnKi}T@OfNs>b=~m#A^SS3jCP* znfblp=CKr#bs!a`oGXC6=C`);GnX@n)E%Z%hI+bNyb&Vsi>Ojc$xS7QcUP?Z;o+Lj z0Q)H_4G6YTtZN)^GL#MX<)dP}lkzrwBs}_e^)xjuww^$J1c1L?E$ikwk?4<iAIW87P48RyAFSa<*Z71u39x3;F5Reu!yscoI1b04@wYG5!LF;)*i)g!NTMl|2; z1726t8EPtvfT2R*h4zE8cJ9iDCj}FWLdKqRk|{Y`qHwfnvQ6n3`(iISr9SNV&hjiL z?NjeOW^H&8~dj%Ob&Y0buNalnLb8_{@%*X?MPIlWd9OZahFh0IY49< zY_(;*jhCk^ZenRbFGOLMZ(AI5&?NIX%(`*8Op{hf6CcrBrq@u{Nr__ z-@VTk>OgJ+;wP@@2XWQH@hhz4SuxT#Aee-)@qbQ1J_1j!J%28awcIY3{I_ zYg@bE#b+#gDRKKa?3g{*Z{8-Ez)_r1dPYFpSv&GS0ve8FTK*j{dK-_;PMfBrf7AF_Y@#blVW9UW zQSV0PI4p~{LX&Beb$_I>4_erd-_BSQBXfErp9e`WvGJYBS-?JyDa+5AFJQkE|Gptp zwWTYj;V{Xz4=$cCl%#@+`6kSveQC28FN8aakWGr`G-#&c501_g-~wvxOeeYyLYn1m zp;Y%xVnAf^2!HgytqKH2PbOw(cSW#fI;#}Grs9ry+-y463$MYgsaS7Q-xX;c71fkY z)ZaELiX%22E7bTzVSK-Ie~ywFs)ZwD*7c z5%Akr{xJ62cXsMlMVCLRkgn7Go&ht_NzLQ2Zui1l(zneYcKBss5WocG}M zUAsQHW}LS2BbKdMfQ($fZ9m4zTg0GvQA= zv_@WlzWzj@55x~IYo_}Rgz489JF>=UNIVTC-Qc&~h~+?+3^X|#9=p{e9>+3YIkO zR2KnTVCLM_O>;7fu==BIesJA3ngh+-@AW)DW{!9k36YQ-w z!+q;gh5fDD)T>}SY;Zc*;mQDBUNY2NfRgBtqe#FaypaKzg;G3JzEkj%y`xL&C8BaZ*p3nD6>AJgJ4nw| zvPYpgF~v}q*(3yguWV$KPU_6DPDxF4m z6)KX&cQeIZ+^#Q+>zZ_8^-2*uPai|9QR`JFIr07ax`ChdHpvCbF&Lkb7SWk&i`jbL zIn&>8n-7OH>)r&C`g*TL_1?DM!=wsJ@Z3R!c6tjba~o}{O2Sjm#+~fPP$G&v-x7jD zIihf2&B4rlQH?oohy0GB+cWIGp?TjOt`7~^9xQWf3;TO-^{vxy*0^*SWiHvQ{XayA zQ*d~rJc_HYK$s)G5KD{u5544l@G4k7cl3Ba9(w9UKh`OIl5wnat|FjcdlNXBMUdKA zeYj?==ze?SNJjEvxz6D7z^NO$;op7{@|zR4C`&?sCznkVxD$ZRNS8&zwF>7?N&O^m zdOunkXg$(nbeFrrk>>K`b{O0|NOWsty==Mb#Xmb?Y$y`1f77FgYnj<4Y!f9GrBi zORKkFNUViw6{7xL_hD1eqhc_x$krMK_%n%6*KHsAC&MwrD!)>dv4BWD{Wq+ra?pA( zD4Nnnj!tixmmpga?(&u1`2bwbcu-=P=)HTQ62FgJC^;M|u4%V~S`%m{z16?zYMnQ0 z)d$C~Y6BKmqHdFK*f>}vo{J@o_7K{eZi*t`w#Kdq*Rt9L-?73GL6!I4&Ope2=pNCTA{$#fq z@e}i7Xx4TNi;Y7l7g&Sb?h+xFO|OuQL46+N(Cybu??r3%QtzN! zZ7W@tTo|h|#LTU@WeoRw$|$pqm|!C-fGM|#pfW_HPO0m5K3TARBCn%qSQvB=Zxds^ zJxCn;OFA9XM9V1>gSXKF{|Xti?_;L1`* zpMJ!&@JalGWyJ6>zX=0cN2_AL2k%n_>Js_bL{wR0S!yZccmq3neLCoTG{#%`lw%7! zErup+rXN2pQ_L@8!Z%!iIpj7aW2V}{sWA<{P<)1ck4Td=FDqgyQA*xksVxoX|tgi0=0r5Z%zk^tA z?SVOLiw&D)?x0M=5h@G!F;=*6Ym(%DtI8y$9Ul4^g3eFq@$ulhOQ&20N`0$rHI&(;| zE7I#l@Lzm{czhCi0**swr8zAqt*zA_**cF&6JzFQy{nA<1zNH$Fa9#D6FktfpBur~ zM@|AJRMiTo`MI}=FzNfZB5msgT%iZv^`K7SyRsXH{8@BT1Atsg*LWQ%OMdp9Ga&qh z4>QqZ$0e|V!X_-QK&z9YIKdq$;CbM!+OI;tHKq#qV5+U?Z?iANJ9NsI!7#LpUb<(9 z9QwunV^$ixJqHfemg2~;I7>?-R%o=yeqFh@YUj6n{Cu#e^meunhAv8rf_}K3`nV7c z300*`#MU)`0EZ=v7Qm9IK57`m9aMoAuQ+WcL1|5XzOtlDCl2Qyp%9g#sX20|^gw&E zwxM_bRG-MOMTsI?ZeHbCA(VfK04rpuU!3g^|9?fzlD>lxYa-VGZmF$6WZsK&AosB- zkn7+2Dq@41p9f*G$LIMzA1ta3MM4;W^;_-8q;YdF*;H$c)ZNVGkRAX6fZsVY=Y*Sr zM@3jQcw8H1&CC`f&&p?6bORx8r%SyS>;cg8Sh?;;zfE7vhV66NSQL2WK@@`1!F>=r zD&!`5IG&RE)#5aNyMyCqmU!W`T*M_)Q<3#Qj8PR|j*XQM{8)LOePeOk9Y}hyHrD-* zZ+#edo4vOa~ z?NDWw?5nLjEC;4QJHTl8eX1p}n}^@C<{Ks*Tb=)DxqEEtfJ)~ov;IiDPQNQBou8Lp zN35cnsjnCfT<#7x75}cPO`@YA~3Z++{uxM z#C2S!sqc_Zo$WmRym<^5o~hk}9*qFM`UkGy*f|@O9s3Z7{VR!8um|3;`mOk8wUJ@{8&w;G<=n$o=F*K#>2=tNyu^Fa^IYMr{v(6;OClAF_%j*4>E zCQzKO($Yi&2=N9XQH*<`_6;a3(Fy|0nwKuZK9U40XyvZ%i4-jdI@wtX<6EN+wEdkv zYN26{p(IXb1k?8BdtUoHKg$YhRsUt&VdWtG?&SX9eZp~~>94oOT<;8mf@hC^wBOA_UPo!Bms%Wof;K@)$DxuON{ zhP-mVf_aS79Z}}>P->yjx^`lg85nK+l<9ovt@e-d7ip%iZOmL-VL8)e=7{BfChX!g zzwLZ~g+|LHVw>IhiNGI>YP2)p&s2m{pi6DehFy#d-%R9`oezgEv3K^v^wxa5&VRX6 zfI3C35#a*ygqn={!u4YuqJTjLb+c6GRpjWD(H-cd2_l3`JgBY)7ZvO%qut%4H!$9_ zTg~~=d}_V_wfLco|8ntjC%T0kKsp8tUp?NmjMD1ZKy$QK+Sx9JitJs^E$Kj}lo_qa+dWV>TnVkr6F3J1AT}0q300x) zG|p5_L@S{~+>MqqMzoI&++oTkU3!2N6v2@P_z}PW`j`HcCWwfZE=d8LSFX>gTXFZO z$PHdR-jHWV4swARIvvjqYlUx5VYBFz>nv?)@y_Pg_;t-bn2Adgx=}SRc#hE+)**Vj zeBIXB-6xysyC>J>(ZA*8)#5%D%qe`jb$L^ucI*1~%AQc1VwdW5qZ%^5mHtsV;?5Q3 zl1TIhOH{JfbXd)3M!VCMmgH1iaIYXZu%SegJ=#$A9||KM@|<@Ccqh(!uWumPQ!Pu= z0Ze?W&zoi--|-n;oO2Uk;PFk?+!c~-pc0+hS*K%=A5m>b!+uXw2X;YGQ#^$i5g6o$niBy z3CyY|dAzn6EWpWb3%yWQuw~^L(MHnvB@;@?fn+GW?mwV z_6c|w_k}J}n3S*4&HN61P|~MGlNYMHN!}AOQD${q>e_8{b$xE$Ap3Zi=XG1@VS1By z9h0`jnzMberncMOT5{*3Uc0i(hJZ0ziQ)08!s;{*Of)fmnbjAH5zT{uJx&%{v&eBJ ziZ{#(In}RSUk$%zG?qR*c>T9_h-q08WI=N=d0vffx%w*|_mmjKXSTJUjmb+} zw@9Ad5)h90JqvheH)z2zDjt``2;KzREtMXY%R^HdV|JPh`+d=D-H?yw^5vbaJh#cNH#yYtYmqx{yr%1s|41g6 z?L3`$`L=Zx@EG>*1j~MCgV^rN^`@I0G%d+7z2?8d@>F z!lMPE5lrV=HKY#RJW5mJtwbuHj?(R;*_G=&{ham4rbbQpeE%WrrZ z8uNX2_r@<#Tq|Lkua(u*CKYBPP*HYxDU7*+6+|Euh|C03<9x|d%diAd316McQFhB> znzK4vFy@zw4)3&FjlJnidH-3VRA!NN=wRKTnHpF3{(WNx%@#;hp)$^Al)DAWuLj*_ zE&oH8f6q-R;~Mp_ZZTTRGFNN{mvE%MrR>NDv_)V$zNnxA2+(6*i4N_RDqmR##Fp%+%w!Cen4|0{zw} z3POvB>Nd=%YqX_NObZq^_D{emYgOv5rd&PseDR6Y>l_uUvN|g7!B_GXd_HOUc(zOImjH~Qsy zia#3#5@tlslFl9^BNxhqnDdU*uRM`e7P!Q(7K=UNDiqP0H(w3}?oPS?9D{ql5`G8) z>33QoD4}km_-3Bi;JDN)I0Twry0aY(GhCW7$Sb-W;!G|(%w3hIl+ClYFOcT!6I7EE zfUAMP)lQqS<=?aDTU5Q}a^j6{HB>t_K*(&Jm3tWuoMWMDESvNOT-oSg`-UpX*mTT~ zn%B{WS|%(%$iv|rRO9QLZtje@!&E8SS*#Ff00Q42ex`!}x7+cTZblM+y@z{^%)g)% zESQbC&Vj&Pt7E?VcUO_+CZ?37=U;%UfK$oykz(eV_LP4Pb6K;VwAy^W-~aOk4!Hd) z*mbrx%T`|Hw!-sHhT8UL_DC)_M`mMJ&tsw^y(jJOOIl!{bq@j+^bt38iG81;A4-AJ zr=-w0spSw{5OEijWh)b8=2~}kCU0uucZS1GNfwZ zB29GSM@~u~Kq|JWC8@?WR%8|teIFUVG~zJ{xbil!ejS6&K2|(~oO5N#K)5B0%|YPC z?kl3WAlV3bRwbjaT4M$EetOBgP(V`rz4@<|PWFp1<0|w8LJn~qe+V29=g<1_?K0>! zz$Y(WC-|D8zUNdlO1I^sESl<_oS@@sO>M?68Tn3o$J2O|2k|a4)U%vr1T%4D>~6t+ zri1O~Xq9j6R>fbwJ&PqpXzcTvDLCP?R_@TB7XD{qnN^95$}eUvY@lOt&@PTrPYj41 z%Vm&`RrHo>rkI1ll`ML^n!mGCrE@yh#zxwz!H~BSM@%BE(V9G)dIlF`D#Ll}CWP^j zH&P4hezZ`E5Dr*VK9@EW>l{k(6?Mf%54Yr+)T`gVo%+UFC1V#}wvIUDSfN`x*Kr6g z+eKU%6ZZ+G;XeC7HS^qfHW6aAqiRACAC14t8h9wC`yd76{vWj!(dVJhwNtHE!Z}N_ zoYMh}m4cjAj%)zBZZ;Y59AVvziL0r(McaO=wjsqRn^Ui2N0N}tDV z&Ve)~F?bed;`D;Tw3(UtCx>#Yn#oS7MKkMy2&XvuOMFJHb~Y>MhceJ>!y;>@96*c} zX&o!CGiMKMpx+5vQpDpez}~(IM-)1v%<^}endU%;LOk&cqh{Suj;^=2yI&d58`ck! z+WyU{>ptz``p~B_XZaA@?cb_4l_xs%JDv)}|7`wlyf?q6Wvbvo38tw#5*!>Y!KVV~ zpt%HC0ujWy{>c!tf3=$LzLTvv#c@zcCGG?0matUr7>@hcjhgG?GQtX{<~d5-_^ z^E3hKl7(aOHEkqh@x8n}-Ze8lwvG4Scr89 z-0R9;anLLa0`lV8HQ|GYhOPm=LnHZTj-m7Dsd59`eo4SN=`YN47huWH3;TQ?aM`xY z=u4If{g8r{ry){5vevu>Ngd|WOgqk;|Me|uP=C_5=O|nz`EV51!re7Cu$g?Ii{gpj z1yn0+Sf<{=tY`ckwUEJ?D0Y`nAC1*v71TE?$sTYlZ^cs8`{MZahf^Lv$-6bIh zy--Eb&;nQ>L;fyp6%aVA?OPvS=^e1yM3*cn!+i?3>%xMPrJ$uH;TwaLmCrdyFKT-E z?A9M|$NqO7-p>9K)ixb~ma~tHvVD9AqA!sgVVZHdxGPYK86A}31`+$)SDpu23Co2# zYR(dxSw}Y+7OHu6hh<^R@EbllxOf{_6UUd3JYfL<)DTR!3l0LqK&Vhm6bl6eLKHk} zzPXt=?nt~<(^!&buCC~dAJRXj?ec&A-hQqgseZmq82RS;dA@vp+cMwr&RYHs-uqrU zb>f<=?(e&S=73hiCnMQ^1?Lpw2ak1Do#Qme;lKCIzNcf?_rEjZu=Kxamf?%LnC}s+Vg2RBYR46hE5W+zf8{@va zhzZ&|Jt(T5syaX1+N@35Y?e1qZ7JDn#w^GZQer%m z*0l8MCHwj&^br!k#BJ8IvV;jjX~5hK=z^3QMnNf)L}?H}(v;)~MG6Uqfngw6bQlW? z0>VKs&}1PoghT-}Sp}DMF0UYazL)S@{*rXzyZT4oII1+BL)$UAz6d?Rj`_YX#R<=7#wnpAee-P5h6}^SGmSy-m$2H zN{Jj5sz7@BFRXrVp?}5Ij?6!v{c`!S--GPOo-d)%s+RGsJ1F{ie;=PDQb8>pMQ>h) zo4)Yly>6eSp?7^{?Ln@=z0G2Xs5Nin@gFC22995(v?x7_pl1$MazR9S1bh8P#lT`8 zH9INk*8I+4q2-A>6`6haXbKNG6NE%G1_gVOC`=WoDWa7wu`F-{+J%n+VZfL!77PW3 zfnlK7C=&>z`MO?JM9E6jR0}TJq*jN_+ivzR%kn>tev_B`&xh8&18-NJV*P2KyoD)S zr=xfLcZEHrf5h5m>=Q=b3A79J>5I<2Lr19a1kgIqxZ7gq@G_9+DTceOGU0sEDbv82 zc?aY*>E77Kmc6f{2Jw#c14VQ+L#!oQ0`1KTK(1^n+-E0Z=U4PvTaHR@jJFTH8T-wY zQh4S;&MLq~)~H_b-s;`wFDVm?#j-Qna4WyWo0bza*0}Hc@X?f%g?UvxMzf98D>2c(`#;oqm+$oHZAGk*@lTw;_BlR( z6P*4{Ba&7kQWRRkI;{=G$~JMLCw2W;c?_aZS!I5wbAfBi0v4^l4{Yv70!5~s>oPfx zce)`+pj6XmVUDRt{3?hLl?0#f`~Ut3!h>+J&@?6+h62Mw5JgIl8rxf!TbXfMA!6<& zMOghdpwqv&@DJtNGkp7O-^xyXa&p;ud{g?XxOd(AmpUnjXMbbgw^p(r|8FZD?eKIj zvUCTl9V5UYOZ= zU@Sxv1q7i$kc9>i2|ME1l2&DLk{VT^EiRI*SGV(P?CZ?={Bn($>guayvfg{kWxSJ; zSC8?ZyNV>92U9ozXh?R+3S_vYP}DX zvI2*}CN>Qp1X<1bD|0!+tD2oWMAu3%nRH^(;hB*yr{bn&tn8?kydhMvOqjM2gYgBG zkf>3QT^;8!A45MErP%IP+1p>i9uwXP66a|9;VW3DX5()@{p%k;k z#e979s@|(}+j^>8#m1qNiqPmf%lY5q4^QY6{W&k%U)|U1q(A2Ed~7*NPf7j0n~93- zyI)S7k>Nt52u#-g%iTK;rRGRjyUhh1O2AH2&o~sEPNU-G?(2|eetUMk1cGk2f`*%& z%fm;do}v3eVF5Zf2BI#tl_v61d%|53nyF1mg>rR`3ns)bWn@yM=+Qz^SAKy|sg+S4 z0=$6$#6d7%OlT7o0>VM4P$UsR1v!bG@q4#7D#${dRJ$rHaFf!9DngXblZ0>in$w-%MuwYlSAuwJK~+usGOUyA-iO;4 zL*H0AY^Y+$BHoI3d=0VVbqjTP$N6&U^)Di+=#V%(K7ThV#pWxxS<5m$KGsgO6$)=t zef9SzImh{?AW%z!eWp^A$3YlWl+kPnw%IP?Oc@9e1aJWOlm3)8XjLLFLTcSUEGCc- z?i#n9-QKev3gahHu~eH)(pf9p2Yk!v*$logo9&r<`-=5;q=|dO&a)UUWYf*Yb)QT`^w5b+$S@ZC=x{(!KG}Q8DCT&R&O{g&&XAzeAj8hSuy_t**!O?7@-~W7tMhi(IjI$=C)_gP_a9%x+($6peZ4`Au(o<$f4wtKRD(kp2XV|XaG!gmR ze89rh(t7u|Xb?8n=(SDm{ff2zoy>eQ=^Cmgoj=I-XZsgry$_9h4qPx9)vA2_C6g}q zIbUVCOE*=9GG97VEK_|{6}B2|pbU6GTq!WlQdXUli!!-@9pIps1!AQoBL=NzgtzsW z04!Ev$F79U8uS9yQ9g9<8 zl|0(VfY)l$<8*ic1YZOVu0muJ5f=tz%2(HFp5V zcM$Katdhl>E9A5mMHx0AZzYL=9OZ`Heb0pMm#9oku4|i}0&l!e|F`%EorHoSP*D&H z$o`lQ_Th0a<-tZPqF2BO(to`7$}p(GW`tGA zGzFz(xjj{eTFaS1>`7V(yWX|B(!*(uQCD{QSedh9N`fbo_vzcpjf^X5qGH~YEP=ex z-yzW6D%pB$iXxSs&Fp9&d_b1+i?^gnw8WrHIVG21c>Fgvc2hESyByz?B%t$hbj-o};KY>dHMmV`r0n`aNBe z|GIF$F+GuZNS9GFyau|a%B|+u8}?bM&Auv`>7yWWX_3DZHEh9HOk{-=e2>%b^b#8ZSc z;13W+W(dH z3R}~I$aO+H6m;LtNdT*!ttZQn_ssT@;N>mwUq|{H&fmpD(&dsR1Pr?pSwt5*a$$Pht*i7G*T(JR$gkBn-}I73fXaD^BMu}`o-u`}Lc9xk9pXA~OJ6!H zC{_wV)xDQ|3q$yiFGV_tQ~g9&5H{qoKMP{*2y&(MV?{wI4P!d^>3fhuPN`=q@W@9n z6B#g|%!dfPY%)OR27*!c58yIVXDDj=rh#R) zdOX+$!I{KIn#?lUKW~A{XxU&-iD82fU4P3Aw~y|P;^cS z!mXDY56vqe-8kCuiH^R;It`>2MH@xzr+;|{{J}eKfnDrvG>NQB^^(w@Zh72SQE|Lc z^fV=c)P@(cvX}!tFrskS-@C`t5)LTR=k!I zkI@;5#wN>48-BaI#yD1Hkb0cvXh~gYEUAx1*d$38mvCGqnego=_uG!996Jg?Z+{U2%g)Bhv+f!Hp;2O? zD#sQ)pvzzzNg|L5f&8-p-Fif^egKZg^6dG~*ae9_QwGsPq-9IrFPcOoT69^?gcQe@pwva3~6T3mpD) zX4aPn&5H&Rxy*9h5i86}Q7%zYrPT%hKDOA?ieU-^r#z%dFiwB%r0n@c<0fr zimNmA%kLi9sSB6NfjIp?nVh7)W# z?@`VN44RZn-XOq|j<(Q)%F4sYPTB@Ex_XP<0l!$^v_Sn*zIA=ZeD=!CM&^}fBUt+b z`!HscY+6bop+8V3>d=!XV(2%dM54o0n zh;{!DAbTBe&2tFDsMTNaNbzs!NM~>)@PBO_qbYiz zcl4*uVx`k{BqH6?|Caf%x+_5GzZ@yi5*(RNl0Fw1t!&F08dC-> zo~nO^K9voMMi$CoDrwbEq}#n!?oEEo_Pu@g zmMX3B5XbcLRi|99nw#%(17MAWxG#Y?=(QX zfE-(32n#YDZ$bR}qrY18m;&Wsam6W4zUg}`0Je-Thi2i%a)A%Sq(VvK9cc+z zyMzaawhSHo#KbXMf-5iP&22T3l1i_GFS|5T493ZK!E=CMf-6IoogF@zWx^)B1F>^( zM6s%bT7oo%H;mHFXa(WAH5GnH2t8H+Oc;X%>6`T#x$V2EjVEKj-%VUIQ(I3j7lv2% zBx{^tE)6@)RzYtYZeq)7d6m~ly^B~-dGtSruwv}Ia}X%*F~J#`PlF9?B#v~k!_ABL z9QlCt*FaNK+DXDSyMX?|+~{wH+5Nm~s+#MgCz7kcu5EO5>a;eazniC-!*N=pWx3|?P z`$>08(B+Est9)-3K_g9vTtel}JBGtAjK5efuQ<1dp8LdPZyd#cS=)sH;>%lbhZQ=u z*ksNU#afZ-`p6gZ&1$RnBvI{LTokpZSinL2y7mjpcFOLAtizDEj+}?!daU7EW4_8O z&UQC`17ogXP}KLfVEHIl*PjYkG?XXH00Irznik}Yr(S1n|QKNUnYsr7ITX{P58eQS^37!e8 zv%nByf7n42t6BDqGyFtG7b@GI6j6#)FC-|iYMJ(v?W8KUc+PYhwxf2vFDYgaeqED( zx{6_u6i5_g)K6UsqYtmc0)tuRMF-+0&5YaPcon1Dr}Tr>Sq_@K;>Y|$KHS{_vML!1 zo=lT;i(6c>(hWY@P16(c6+D7qG|GU zaDj~VW+bmHAN6-aq^#+mlCDuYeI(UND|Vw5ux@pFDGcdgX|N^!7S7ZfqU%GnWpB&O4;Q31gk3hkxd6!!Zo+y}SQ)rj3bH~Tan z8L{bo{Cp?aA6VyCcihKa<89-=B)3%v0IP%DWYt1CJ7&q|SeC91cSo z&b_z>(=U=f9KQ4gPY?PqVH2{b%J%R3dVn3H_D zSjo}(BK1wfw~HHX-|dIPI$`s^lzOP}@Q6?xidyTFRh|=ob^QV%9CPg3RM)CKXgZT5Vu5Yk=rP-^FQZjk4vW8i!Qad8D!7;cAnl(*OI42{* zngAvV;@l-SfxzfnhH}GYH90cs`J#RA5;FTPXvdGpmX2Np1Ov6JC##x5FT)p)O17uLTCvq9(6E_rvyEky|_6L-!e3>?s)k|6&6TXxgun?vUCNeO9Can z3m`M)Ly_M}CcxxF```y{ofAhV1AcLObN1f9Ll(A0yc+C(xn=L0ajoaWaTTUUlHhpK&jmgpk+jh&~R-Spj^~Z{ zJrAJ%5Bi_gTOa?$!VS&#AZ3G(zVbOvRLZMt&+rNJZloAZk^4~vdRY$QV0Usus+b%| zkkS!CPUz|vC!9E%!lHEzsp<_Fj9{q(q*}NH^{k1r+8ENm{ohm@i4@069`!X(JmVp| zH`*`kc-HOhWs9fB`a{t_-_GYm|$+@wmpVeOK?!1VY9&e#A+r( z3~KlkO-v=ozETOjWOXP78@L5hHUL5W6-u*YRElFwy=B0LRcU&4J$2p>*e@1yBykQS zp(!F9($teccB~{RjKmPjH*a$h9;mDyIg+_>#M3dlg?yI0_H2GR&LF{&2lx@d0PfTf zEI11a0^wl5nFtmV34((lh)g1fuYGeIcUq}aEg7XrOOuMVC0N++_GW#f*_!vDiKRu&Fclzoi}Nu3!UFut=&}6`n@_eQTb`P$?n~c^{&yM=}${c692WuvpFpeNn=#r$;!YEM>Z$G-bcqo7qjQ)zn zT?H>%#KIZ6+KVnlYS`jXW4doy&Q0PvZV)UL6A1#xfY4b;A`%5cBru5?emwJ9<`Z*V z%dHtCPIbl6DsU?2`8{tN?ePEi9X3-(t9_YjldII8N+ur)X*;*6AAOe>OpC9H{@QhW zCYD(eeP@Kd!x$fDbtybh#oh)OM<#`$kfvVy07}`H@K|+N*Okvc{tXnoLloSNI+ywn zK5Jp!>2A+d?u4VlKc(|v9t%_;);hhvZf=FhlSC;q;4x%+z8r(+w))Z?)t$%xLHsZ5(WapK(LU677+wc-zC*?wKmgsmaC+!zc#9^ zKu&+3?Okok-ZZCU{QZ?_o^|Y%J-OX>o}ezL^S|-(Syh_xqu{!mdUnP7NZFTPK)>a; zUHj_Vk(gKJe$4*}6arP!Riy2q9-6de>BSk}v6|NZ3aOz*LsRfwA6(Y`z6rDBpv7|Y z>TY`4L@7NTHu^C%nu3_N9k}~>6gU(BfiSH_;j5@}veMZT zS_tRT2eupqg5hF7STGh^34(%PphYLX?r!9(<~6xxQe55D-lY{RCldE>rJa{o+uDy$ z$MUuM<77O-lk*GNR=p8^LI3l1Sx*=1@fkO#dV92Wv~*g<{~k)jFqbjXWmPxT=$@sP zLzir?M3>EUT)>%X1^u>(8Xx;41^MREh0zjbC4&4*+{_t3uRzrQJz&*vdhQnIZB>Hsp1Ka33&tvAqLJyO z9o~nyt_YWsNgRur>ch1@UGObuc3<${m_PF!Uy=(vy044p%_<0tz4!QTOtU3V-LC;V zCXb>PPV=ECroaR{S~t9wsSN6qc!PmO=xSaT0SQolE$hGk;8ZNg69xprK(L@FH3|iS zVHC3wxmwHRYP@h62)j-fg44&&EnmgL#yi73A>t@&Vpi9u;45-3kCy0V8B>t7787Lp%kjOxs#6g z+{qPk8EBb&MO{?#2cE4>U;A}U^p78Yk}p@^&!&6y$^CcvyzS7XlkCwuaEAkZi~4I= zK zHHiwRO+^%pDL{ng1Z6?<{=WI$fM~#2C^i}ef`WjkMCuU=c)Y8tVUoDZ2usYJT;8f> zj%UE~>(|%2uV;_uu9!cYe@y@Na(BP3p4jfEAJ6r#$v*#<9}eFh>F?bgiB8hBNct+! zf77~MTk`Jn@|ur;1MX@S_6YAmrrqDE7P+Lno~pnv)lf%~Gga*hCljY%VwPX*(dGY>6f%6?CQemCF<9A!7HXsX5HH-1DcbsyL;+8lxq$a9E-I= zOOoJ;rLVM>spAg98=dKvIG;e#or@cGh|frWho%N*kq^f6I_XAG0!h4%wziu4Cu>%E zmc&DxheIAK+X}5&6+=D{MH=Nn85rH@LhJb^1`LD<0yqHplm43+ zOpvNXlR^oRs?;tF&aSI>FD@1AZZ-h*pt9zxx5~YjemcD3Pn207&Z?ALj=LZjZ21wp zQtlt0KX<@S$Mg8Ai{J0c<_2QPCVi<*@9NWtnY+pW14g-8Z}?t`}^E9%dWZ2GU}x>2Nt)RaGlh zbN=JA&ttK~CuepAfQ2*r|8KEuvQmUelBCWTFQC0*+BcU>nl$BSY5Z(^dTLm?)%O(z z6be~d8dP#C;`h9M+}?D40jv89D~i%Im+H8vnTqPj5H{w_2?hZQHIYyxdpk2xKj%8k z5ga(2rGbkoaS~iRdO>WB{Lb(a+Fog0LLp-f(jA-vedu@Xl2KAI&Pj%;?yj+?bti3w zEzWpy&eVB9=6vKMH3(VlqXnJZgZ(BF`yvO7nt!eW(x<9Yy~;?0rcx}i$?)ozdR#5t zaegs1#c0EdoZ5TJy6Zbv_V(Q;jys?zJ<(AS{Gj;?j5bP10aZ(@YCwl(udW>{+cEhF z$gwJhURCbf4DQ;#W}?EsU!-poocU*YUu0FWN84dCF#91-q#9?4?Ah;6t-AXWa$*ZQ zeA;2JI!NX=^MZ#QjW|1yHTgEC%+S+LTVEB zc-HjKCIDcx9VgE*KN^iOv27~??Z632m0oJU?hMyBn5V;ON0h{30xzm0b&_d2v{CGh z{mo&uL&3TtDF%S!3==PGgY1~rxCGF@1x*XgeAm|3NY;vg;lmR!doE27;Y;-0QBMyerhGymY zwHIB|Z$I=!X=-mi7|X)Ei;;#S@lzT;GR6u-K20H3tVqjm%}u4<`kutMs7Ym_v8wNe z6*1Y7T1bF7W+A_aSXHYL$VRCzluHGCk|fkn#=kCA6CGMX!{rfAs^zKW-#WvRDj3aU z4(YmE)EY6Akt$tXabvF^tGko&qn8i(DypoebAe~Q@^{9P>e58m@s5P z{seFU01Pidnx-I!|Nf_hcG~a~$6$Dc+d2|Y(Z=z#pjz_`@-1vKj3duaI7lxpZWM3L zjVp|$$!}qI!IL`WoGKcia`aK?e*XO&wSy||$KGbx!>pNB50e6jLa`2*K}*^nhETWD zpU4E6BSpt)j$Dw%8$u1;Y(*R5Vh~GXqRcq97bQR*?IBf_PT&2ZjYuCXi@Oz>Sqajj zp6>ws(=y#Ia+Q+G)@{b&RKCe26gi+ItmV(KjqdCmrZZ{yh8pXQjB%kN*YuIX<2&34 z+sSQfYi9LylUdB0`dM9l^{-df^`0}6n;p6+?-CO&Ej)dli>~SvzmfX){-Aw?8mC;N z)JsjKygcfe{nOs=;IIYC-UXRfkGF)Nr7mWeL&Ep0xe%+j04$>OmMxW5U%l59U~|{Z zSH8^wye>1$8ODuXeob4Bv5YrHL>tOqULSi{HkMArECMV0neUv0n_+FT5q>e|9P*zM zv^cC#j%5`V;#zsl-F`NS55)3l!Kc_ERCdG2wWM)fUcGr!g_OY_JAj{B5|yS;@{oD{ zBCYEkrUb7}n`zMoP62;F9OBSO7B8v8jrx2oF}xLTy`ezRCaI1)LKOkhu;D(19di^5 z;)b6P6?)%;s*A{~7z#Ig+^D)nzSMkvFOqI1%hIx|K%SqU@!8XFEivl27u^l^ zQs&Po1t#y!kdC@KBLQNsxUMoH*SgvGG`I|v9%yoHh#27^-wGoiB)`K`8ciiNWEGiW zV?vrv|Gi(deKd{QD6A%xZ!Wcu%xVnxJBDPTp}jT^Rdnc6zuICsG9LrMZlD+#;LsLP z4_ugxO}OxTfw31z4_q~|L4mg3KlHDwgbStXcHcT@P{2lEG-BH#-;l#90y6wL|L0lq z51xHyLYQvxrb|lyP{s7NO8mjK@pz+d;*noD0zt@<+b1_X6LL&#pI|)31CuRKia;o= zex0{-5~B04ubiXBx0f&9R@1Rw%%PYbeqL@|W55yL(^GHgV+U;j{q{R!P}!sb|^jb-wwJkj@{IVc>96*^s@Rr>+U6~`QpNLh=36)%)dYnGgS;#A07fc@DD zOCT>MoFbd^3KqatCL z(Nj~-5GLf>2}#GKF$Ou+Z#$d~W+t?(DZ7!3pC!YgqMJ##IoT0bZO(al!D#QS{0@XI zX2WS^bBO`TBx%j0*I4KXxc(<7y2i1XHx+ve*3Ouw<2+d5}uFX`9#$i^x6?dZO;*j4Ne>%8AFMo8v2JXn?EV+6j>5&{3t8_BJ)}--k zQpt(F+vXtj`LC(^28i7o2BObgJ~Uz~VLovEa8W(-fY@58f!4Nqz-UpApj6_GtM8Zu zno6Z81#>OQv|3+PM9$WebbZaslWa5a7T1|(N}kGXTO51J5=LD?ohKh*!4;NwX3Oqd z+3hj*A|i)i=U;2hA5|E`DD zJSTS)0A65%%=5`!Efd@AoFbQm{cWzms;>yq~3?LV%$m{+bX-Z%~o&>{*j2j#0g0d=0FzM-93AcK~S;P$oOFK9dLOW{iL1TU12u zu#R&ihWs3=wbjVxXGKC9KBEkh)LRAhcz+#@J+t&UKG7^^V_FLV@qS9scvoS5MAX6Z zx=^5KQ4;XpBE;7;Q^RstQ+t33H^{3)JYytXw;!+CaVZYGeMd&jT60b!5ngW*%=kNZ z?>!*yY33c^uB0qmF7Lc}_#BTiJ~M#x;hbv7omJ3x^lU43l;GAIQZ40I^KYL;9sn-T zfPeJ*mILIZpLGX3Oh*c$U>5X9~rk+);Uj7hz!0Dl4-vQ)PNJ>#l75`Ub%Vq=f z9T8ID)$9e~*@Fi5zxO8uKD`QrYJY=Y_kK;$PE-ACeGK&~H)s z=t%20_K@3jCFZ_j46noCX67*ugoUU zq*QadZ&E}~CgT0aA<4?FIg~CsB^5>3UmNYXeSY;J9c%*Q2=tD3+kC200-)X}499Ce zE(IoTZ5it3?`ZMN;**Uo3mj(XTK2NzO|!!irf}LmC&%&wwAyy|sttxW16CM5F|x)e ztSpA*Um79O`}rIw&%&z+U}NIH`bNDPJMXN(Ka{OZKc|Xf=8SO%K>H}jQK^}RqltYe z@s3ER9I<*dKi?KHrS-4%La5Y>0Y){_O{;_??K{C2aXU~B@M zL*&S43xKFdis3t1ZWz3V%G{ChEOEcY&KY^GnqjXaYQdY_zhcxJib7#Y7%@RB0b&~D zW00ZCPwNyQfEF>tJ+mXY#bQUGWN4NSp1D;yip-^ICr$v|KqJ3~JMQPew$vSMiMy~g zMDr#1_~a*>4!$Qt;W)d2Y>|)viy7@yH~n5hX43#x?$uyAEWWVsyNtoTeTO63eW+4p6%*cBR7VT=__S8NnF}dl;0#bBn$eg zOUz1tU{8=@Q2l{+gCw3)Bt5wOqgs5e?8t*Z>3H-2oBQ2MXxj0aKp=-H>B+W?ItnfL z4teRwa1l`hcYNbpF>Zu%+xCTuOQAIfb8}UUZIME-CwOb1_6XJ)Zz#hh87J+pZ~lGI zO1}Xu_syG1N`oz++byfr8d1~4yw}5}BI^cQ#fa+B@wj5=vCe(8RS=D%!*7>$^~ zFhmAwIYp`2s{d*mi-ct%YV;%&2yq{4(~B*F9fF_+jafRHnyNu z3B^wBMqN&Ba~0!R9XBNDRiwOc1E?BO4xDOa{4RV?I)yRk^E&EHy6;gB zj*bZ2WtQpsseh9bQ>q_{c4vWfxQ61xdtB75*OeTB+1vzJo5rmDw?iz8>o( zuGv)F5#a5KWJWH+g%d_nvP2vi*f)_YQDRFDa%$;NpZ5JrCI-vMOR)?fEIaP*$V$q8 zcVvhwo-^)ZIuaz}3smnn6tCYTQin(*JXu;U#PuBqSNePQ7x%}P4ain;2_ARHFRze$ zum2O={xQl%du{TyFW6Y2UUK|p6eNS!pHI<}e~|cr?jBN$Eo#e@0%M&fx2m|`r?mm~ zrrruLzDWw{N+bw0*#HcQ`Yz%YOhpd&IS6*k5Giie4GdGSQJ;9A)3bLNROhr$y zOo-7o(I%(gZEKwbiw%5C+GXviHG0mfb~1^ig>JE!V$-s)$8$*Laq>oJ;mMLyUWslG+HXZbzpREfm@-R~&QqU>n_b1soO0*@PQB;1%bIVku zo}54Ste6!_H4$FnFT=V$mzJV%Y=Q~Q0kea6mH=gxaElDR0N6P7Htnqv8sKqyEp5(s z2%DORM$cDzD_*$IRkn{&y;QT&BF|lKSZY@y275~2gLd3A6V-Y}`}22bmCWZAnO$sr zB02_uYH=zRTPOVv_K(r-Ll9WUx+pP^bYdORK=%raHcC*D0?LwIYT(}6&ac#La9oc_ z{Yp5LU;mFywXmU?z>V_L-nNs}v%$w>GFD_TuM1Jk9M|c1l~qc`skgy4zSGH_D0=BN zBgd=cs=%4;?_a}3gk)`E8UNFWl>>eDW>84aR+*a-JQ*#CI(p}ubFY;{YI2lg3JrW( z{@$V&rX;BI@0NS<Ob6_mc+O7R7j8b;~6;T?vP1)4M_PsJoAbl)jy@i1l35+-o?E z_Hf#BHcQQ@{Aaf+whVmGTD>~P28q4$Fg#}pRRSI-1|3+aXo!s``~G`_M$9%!RI*44 zAnbuX?MK#9sQ3Gpy%#S=h+6O8g=s+_~*FjEw`kkw6?^p?*WbP%O9n(Hiv zTcqr&M5>tRx6f`>(@hfGlNL47finV9C1m&1qK#cUVlKuk8ym+{Fz~|=&ud8KungeCB9t-$2G~ zgz3|KFk4=GRV=k8&IKC1HsqBBxboEf0!Aq zl$2#@Y^I#&ThK8Dy~>EApZh50QS@rC>9(Eg5hC6 zSa23935J6ps6{VZ&2y}^REQ*;<}$czN@sr zdaB-k_Ws46Z_X&vS~u8^C@%NHm>!lIVrzvQrsQ$6_ALqZ0ELR+6c9m~U zdy27(mb9CkiF2X$NR3kHR!H*1`i1#9;C)s=cS_U%`mYJA>`gh4sBz)Ym28nDeD&EJ zX`FMWrD3H39w-#Jdl%Y@NiItw)u4k!J~V&`T7&<+|Np-R!BDc`EEo$80>nWmP+~&} zkiuq4op`suF3LEqe9 zxR}}6V*R*t$?V))KW=G;pQmwz?&DL}X}uIa6?xybPuH9LYVJS(+t=Y6nvnecO{-&8 zq9Aln`*301xy|}fvwfo%d1s=q=%Z5kwgc$yUN;@d?T^zR64Z9&lzq*(=Q#;ww-D}VRD{5eLkH&p8XQ@pT3UEJa_p&P#1oVrUBEQeTedhMaj4C*+#ruZZAcbd@hnQ zli$UfOzYC;Jf{-R*^DZ~*W}S$zG^G#&76P@uB%nkfR{=b8`E`P9f2Z^W<)cOK!)Le z-DfWn-|qm|%c@XV9ZNfbKq4NJiD);NoHg{KB)G8(^(&sfQ7trA(M6K97Sk`#JT_IH ztCEX4BW|D(wFiIyx8VK!!oz^G&@40y1qQ)EC`9SaRcfyLOtng`D|`#8mmC!!{K4#h z^VPdkht2l&b$NF;DSNc@@UumIFzoYYz4%9&w|%FZ%$8xx$-6%a@4{*^zJB=C%f9{- z{)gf{=5kKn`pzY;&+=(FcH5L$!2uqx+-#qN`Xx}qgef}bufPZlqyw*h|NnK`L*MQD zUfrQPRtPf>M z0S)YPvI-UB3cwu@EEo$O0>Xf@&@2=ogi!hS>wexRFp`v)Nm5LUxg|qG_X+E3LdcRcw&0^Ss~BBsqFF1&}0XveZ-)FPFo*YZhA$dSddIl@*e8QuWF zk%0h50Q?jfP!==|jR9mJSSTS&H=4#;t5}y;iC0pR*Dh45;BVvWK07YZ*QO2s+%@F};I>H5T?g6`e$G6WZ}ZfyklB+vLBK?#B9jXF{E#SX-@3?^tba zl~mKoK)8;Nj$pFcZ?QP!s?cQB!DOQ@#Vg1-Buk=MFi8@X+}z_(U;uoZaR)9s(p=sm9A-=nL>zcv?p zzn|BCOPS&9o)YpMSM@}~5r@~C(mG36T{<8JvNJ{ zURP`~s{AO2W#vDdCg4vL6}uGdt#pwpjnCLYE|UZ#-7tGVuuv>#3lajwK(JseBpU?+ zp&*D%B2WvyZ=O5W?^pF?$4v-sVk{LZ@cz5s%Y!|y2yl(eAqgI0?_{$L zxcYg;wEPSvhQ%47C{_vEa?lX$qeBXw%RDJTXuu;155Mz&&+Fm|g5hJpSk4v{1%!fd zuuw`82%*Qm)4A6%GU}$c5>?8j-NHHOa_{5K%;P$d^DIFgR;)B zrhhM&)yKLMeV!j`eL6Yiw~w_wdz~jQ_xD?%t97@R&%EJAT*o^yk*E8jB~%XEcp)90 z(jRuCp}GTl2oTU0#g}oTV#)!Pa zT0E#1p7B!t*&m2cHT_VCa~P08eI>~jF~P6FS{n841IdK~!htlEyK4U9pU4qXlXmxdYJdq93sAY#uBGogDpi{Y&7L$Z z1@G+zj(8|=a5#%bbA`TXg9bnZ0UQ7T2H!!N#vq6P{-=aSQ0PO5g1Gu^u^C!*ho*9M z>|f%6s9_|`&j9_&w5Cr}cnlVtSw^$iB(XtDtOenh#u|5yUKpG(e>LT#(e_fb=;>bv z?Ua?zWNOUYVDCb{QdS}s>Q8p+@-a5VY>QLwB2METTw0}k-vptE7`a*$Af0uro|iPo z?XTatA4q6#4DBP3x$zT$@KF_HH!Uigy32aVVtWC|Q!^JHH5lguHnJF5AbS)h?XCo6 zqn>5JhFrzBeBvj=9;zu%RND^`H(lW$Cwwb2wpRwqLq|k*5v4D9O%;L66_aG1J&5WN zd*5)rxSYJ!bj#n-r%Np;`jtsLK}qhRNffWiwPqPeY7c7Xh2o@PfBxW?3l!Gtn1wlx z{aS2mz_d1U4P_L`H&JPN82uSltr+ax?--_Jswf{R>Fubu$3_c^=gQFf8LV?C$ zwgnVBSbc1&f7Ap@I!+67*9?v@aSJ;|l%O20O&rYL3%fBb0<8CUoZdz2T$uMpXJRS` zUf@jT?zvrIu0 z?2;YfLCaZkpnj@g1DISZM`S8~<_6X0yNWnQV6bvFCtufB82<^e`kC@WgE^$lRdaoJ zd{rW;@Wf3ZTUE)x*55#dBLzNF4 zJz;9YW;AtidzM~<2XbZnRzkCi39k}u=*Gev|w2Q&66^ZuK;0YZM?XQ9S@LE9cMSpzV6mSRz$~$V%s%jyl zRGAUet8j~re>E<&sr34n>uk}p{LJ87IByO9vt5yKN2!f!4ECl2F#O!4FJUj^X>F~R z#}b#<*hL6ldl|wdLuYi;=fTmD8*L80zlyXMM2aAtglJ>V3CO|;C`d#09rp{{{fh4g zfqr-s=POL?a6yeR1?mb6eCWsL>sb1btfD!Sq4my{QUzCJ8Kp<-ZLJ|2hbI(|7#s-!>l?hwU;g-=cj(=DbcG&=#j+$u|0~dX2)RjY{=dc+l6zT{2jf7! zd~*76S=og#a`NP$Kh4GQxDHbddXb*9Fcqjjaj071WA@;85pJ%kPAE4Xq~$4l5{6_z>sh}FQdLnJd#J!CFw68de?W3559|#;W_WHvgM~6>S3f_nBMyzve0uZY$j~)j-PXNrt92{J7X3iyq zhv=Rd)!VJmjlK+Qf2p^Qiyjz-O#IXI0Vp;Wi}9_5HXh?T{AXiQ%q z!WCBh_0AaNvv8Wjq}rOHmw&@)-SDyX50fDoV32kfZK~+Fd^3?CB%GO7C-6^Zuyn%) zDoTyQKl=c+E;HT@F%Q7rJhIG*VDsvYHOLG@jEi`jfueBtv zH!uBK^o4RuJZoL8^YSg+D^=!<8ZptBFuQ#cab+zd;z>ogh&!5BnK{UHLK}gREZU;8 zMx|)wTrw2Pwc1R7MJz;AZQ$H${8pnnw~JeF;T4S-U?3tR*Uyw*!UX^6Ykov&NiBTG zb8JXS;2&1zy7C^3cP|fGwlB0md@)fr5l(ua^lNnHcAR4-cK~zt<5zX&0eGwnW#x-2 zg*%$?O?$u=#_2i&@b3nUw_m=5s4-O?f@inj0CsL65XSp7x85Q~%$U06)yUmh z^fQroi^lfRDoym(aO2m?xSXYiB_N;pod9J}sT;7b6C<6+BikrPHM>D+T7>Qhp)nEj zck`>h9S5_idkrZ>NHLC>?I?*=`~};K$mHnbt?+b!0|TnNf)gjbOF!zw8la^_TyuSp zFg0J^6LZ2za&=QWU~G}YNwB*Ckz>krXxoy&vtsuSLLZ)H{-yqx?sEbZi z2Gg0rHuVYdM6(s2a`b^NApryw84wl}1()e}5jnJ#uL~&8yG(?eD(avE_z*D3cEbmW8%noAb`6yPke|EG$lu z&$*2CPHL1&zIJkKy3~(K^7h!7x3rn(rb&}V--~Dc^nO<#&&}aU?Y82hr&OMzry4tIlg;WK zJw2H1ys}vNJ{7-#!E<)V;F=21%iQNz*uw^ zQV~LdFo?_|M!U{#`7EkS$r;rpNiLG}S>%3PwEaU}+ILOi+C7@J|Y=QNgxt{l98ASwyz3 z5mJI4{IaO;ao&b+;;&cj{ojU;#*=bN1BzAsuNd}h);F7~*!dGmi-i2*m6+o6E8ga% zMzhDqZ?PxG=_4}Fl%kvue|^Akl!_UC1ca6j@YVqsP=Eise*az&EJzC`0?0wIP%IJ< zp}%^Jl_e=!tIck;3&}K*8{ln2r*~G({5PYk`)Q}s&$?dm{Jzcq3Ax*L?{vx2{#T~V zEZEGER=M!A`n7)wzf#J;-qPLUsVp9-uQ&E%A##6)ev0{MN_x(CD^Duu;XVl}Z`*v= zksE_#;6V71!DhKVfC59hqG4Dhuyc{1?bb9wUEo6EEkYH~`xv4_zf4jRVqGmQ1u;r9 z87S`+?4_#`kt2T~BdE?YXw)Sh*N8r#m{1lZ1&0A+AXrEy3JiuQYvz+R&s&vOCP`GX z2`zGx#+A^wdo%6S6WuTOX8d$`Xi{JE;j^Z``a0XYJS}XuvMrsY|HLhSOMg;Ojq>_u zHN7P&-ff+~Zo~d@@}jwEuFNd1;@;GF9EAR}*N6&eUz~hx3b30!>P7Yhys!GN(KEK~~(0>VKkMK79++FDd9RD$X%^te^# zg?+!(UC8XaMtvw~Xlu84bM0b3NL%|QljI$yss8$Tck%Gip!|LTHlVM=R#aJb$?2A> z#dvxTM_9FAUH@;-3em?a;crLbbd+Bd31ME4M35vuuzla_5sqd;bs9|8fZuGwq!Rs?R&_;P}&O~wVw63fjQZGm_i-#6!RJ1NJM(d6SU^&s%7|tL< z6dQlP|NrS{hrT+i-n9bX4({!`l`~Kd+X7JJX#?Qs)oq!+Zll^-rtTuF)RWlx){lM=8JgwAM z#rUV*p1LrOKB=0SlB$JW5NV_9>h4uZVzgzOWC2!4hRFHyssg$Y==Ph>a&n}XSL{af zZd!PuH(Ucat6Vxm1<@*6>~)gKgbhZxjJHbQv4* z&X4Ybc<(erjU+w6`_q^cdzoZ%vF7SYmBG}ox}TdyOEY^fMcZfj!&X3eu_9R{O`=*F zrl^UE7R0EhDu9eAD4(1CpRY(03k5>KfUsOFcnc*6p&<$!?zy%-yQ|Db{7Xyu3-jb1R|=CSw<~@V2jo0w$FqD* z&?w_O+Fa_*Z2UOafUQ+C?Rw5c;x*B!+O=x*u-cbsp-}#lP$TH%dM@2^u3z5L-mWH$ zCODJ-i&g9F47y$UODJI#?9uZmQ@ zfPhf2piFoRh62e#s7xXf2#CTYYt?&t%~j;QLatR^UPhwo?xtvc1Nm?5!{gB-+h3=~ z&mS(2jNXsit9PGZ#LD_3=2nP04B6|i~Vdb-d?=Y5%PmRs(p5#dz%HGgIoi!UWhnuc>+y&Lpe%T1lw1ty-X7bJ8hv(8Ww`0Kt#}07n4; zR2V20k`07`V5m@Hmg_OA=A=nU2|`4Y>?+35`6t@1ktgP6c`aAqyk5Z=u|F_01(3g%}C-H>VBa?t!*PU8g7OCTC zu<>Q^%CiVY)T9bGrl^FLfTUP1eR3$OigXvxm2e>{3;+GU{rlntj)7r7P%KCj1_FU# zp@=3COTQC1j%!=INzJZh#FDica-&1S9v#>F;%_F++^FC`toUEQbr8H9vtJNnfsZhF=sFD)L?<(~4&cBa`6#H<| zXP3j>+5Q@tTFQO=H0!Sqzt(&B?B_@m_Er5b$K#E?Hc`Xlr*|G@Ho7>amFy|*dTOIu z+vcZ|S8s&3ND6~bA1$#?^>d;@&Obx((f*A<5gsSscjKD>LT&udkR+&+kTG*ke!t@_ z$alSO8n5FaQd!Y%E3Gy23&KxMB(X0W3Ap!u7000Yc zL7L_uhyVVkh8?89N`56vuBC^!vwyN8{ugmbTH|}n-`WxAE!B*}9sWQ^b{+qI_@mTJdqxW0v-&)L(}>^hpNYA- zt*0)Vh+dKw)*ewG{?KGE;=@tH48pEO+GK^R@%lQlgvAIB6 z6Gii$$%R-rzD&fd82nX7pFr6b319aBC-}y)?K0tz(qfr566AmY5_$h)Ji#JhUQJ~U zN9$SPgkuN5I?g2YH&xF9g`7B1u$+y|)4bAiZ`-Lj5cIpiaw>l@(&ew3PH@31x)E^ebFE_~`Sz2-fn%nSkVufSpYT`MAxG*Bxx) zL7Ew<@I>R8t&%LPHB!+K9PBW{T?30S#&Ug>Z0bt74NvOQNt=L;AI@o|7l3o-*_6`V zbu7MCdnhqR(lsY)$TK9)&EkOcwN9wnJcLe0FmzXaeCO5ME7)fZ0O?}<4B)Iz*kL=a zOz#24SZ7+?URjQ~+{;kO)j=9|k3%~UZbJbUiFe{{+HBK}lhhpb7k;X9SkOc_=MeT| zvv52B#st`t13VQ@QdyY#WG)8%h@IK@4wzXy1_tPc1eI@g5Vt<-bnA$`t31iY;Ii1v z>9u0x41n5sEOHKO1kou@aK7DmDrtbk>!RD@Z+WwE3`8#LM6`Lz+U|AtA=HuRW^)c- z_>T^$8h<4>>+ns%2}^NHKc!&z{)Arb@oDEz@PXe_d{kvn!N`i$%wMCeUkMVJYQ2AL z&OG4)x`vKyx5q$t`ZWgNNhQgP;096o@+b8Pk@IPaXYMSKm=;E*m92T?xjP`uJ@`h; zSC#7-0DKhOV2ry5Sdtcnf1F(vK-D%1KFQ4AsX48XD?*}S>yt&RybQh6rw@~S<^S+R zr|fVrUl~14A;wE~v4x`m0IX z*lxZfvj2Ik?m(){LEDa#xQ-`!xUc4na~?L~tWNaU_UCAyfmFmRXCs}4=7Y$Q%MUun zQ*Bf>0?I61`^1Nc%c+n&*cEEXKf0xcFdal47y zC9l;&3gW zrizTr=~^S+Gwp*sA4m(Bo}{&X&4a#cKO(J<=SHrEIw_Rp+f=JQx_FRi@K)>D#}|un?73a}I0YGYtd9MT`VhU(S_V#H`ka zj&_r!|GcBM4+*U+It|@QsuNG_|wosKCF{niLly_ zku9E)OcHtF5?7%Uhzz{Z%}SuiCE>_69A!7Kj`C-wj{R`rgW%92hwW+>gwS zrgopXo}f44LoUN(m=NBz;r5!&6ok337_^x)NwMU&uz4@#kB*xwE0FhNvvh(e4qR3p zEKg*$1$*}M$8dQv*?)<0G??;ZMT$Z76X?bIABh>7jO-}tWg_l&%gO#8Z7z4>(2Vu1 zk15Lv$Ax`1|7%Y5+qWGzP$ z55jAk=T|LqOo&5ppOR`H!aERcwAHvib-Y?Mv`Qa2TUIUQrTG!^EdZSix8y@vxH7b9Co@d#~uu=b`%`b>YG5J)m z%Lbtr;abu|^r8dPT^vOG3-Tce9e=G&$yG`8YFUyydb|$|QGtz|){A5g-YVq7mK_Q6aKF=+D6P zfC*EPkrq0bzV47`mV(_TjlUJy)#qMyDO_Ye8^5o1#mh^kPC2f)a=I-PvX>e~5RCqi z-|U#`biLZH6s*x2`>b>rb`*SU_tu5Y5zl|gb|%X}L({v4&9^ST0RXe_LCWWBQ*yFn zI~)&VY>smHRibYl98H6G$Wa7_=CW~q&7P** z6%*P~GwVvL2%ez#wP9J2+Xjgtd;6Sv-SocWEw2W`%gXg9`OWBarg^AMx~$}B(ro&a z9xm3f40J~D>XUNNxfam0yhSA%h?qH2yy>oJr7zuPLAIa1@5$Mx*Dag#BqH!!O^#~> zhruu7yt0HWwH)|>mBT%_AQ-~lvjN6le)C*TF1SKXEt2_r4FpN-(?e&2-D}|8!ys^z z69##7q{l>8p>vd=J$xs=B33lF!ET6{rVw=J*;02&m8?u#%-!~PQkESGi&4EU1$lLtp$Hmd(u7%!`Hmv%qPgf!E2jYx<0St8b!K^{jP) zl@NqqPxm;M$@>u~rWmNsoreS%1P!R8PdVUNtfW|?R!_+M?@zIAbsYg4H_h1J+-hR= z+m9DnQ_D2zK70LXZ~y1%;RIWtfZrTK0(8wVM5j9(#4Z(rj+k0)tkZItR_Vk%2uXyF z_GCD0P3}Oq_yKMV?R!m$1z-sHuTWhvJKNY|u-x1GV~Yv=?Ey)InUc>Ki@u5gfq@jV zAx~1*7nf=B74J196E3wj?mQ=)0*F%cpxX7|6}kZ>XHg?xOaR}&zrLTZjv>>yN#Z&F z`S$>qaBg2+ib|FIid?t-@v2Ua)~CqZfT}<#1bAv)_!CChEe>$0Y^wvrN)V({8g*)H zNYNn^%X%9_8}iX$wDdvc0Qi&s4Jb^|szhFgi?q{gwHlv`Xjff31Z${v>2b9vQrirp zu3cPSa!|Qu*SXc}jMap=m&B56Ypc+Q+DX^%nHP$=$u8p0ixW{0b!5?-P?(4XOn8N7 zB|TI9D{n2DiM=X$N4iUG=UHowxp#eNZ&CSdPm!n6j@Y4Xo2vbCWL3{Fv`*;ehAERz zouE|%#%hAu7%WwH4a8i0ll~xE^uOJ4YL}W1QXC(dfUFxAAxr-M_sTI@Atb7;F75^P zu<0-hut;CyMJVwY*r%a$an{$fhqUuKxiRM6kK99|J>Hhvm*O{8H$oaA#4&Kw?%0*jH7ORwzRMOGRiSn=j8cG->b59q$O+#dMW6Lhp$Ekk}PKFqNSaLRFqg9MOHfo zx$&CXueO4VAgHaDG0UW?22lZda{xC`!a|fmZkW>QWD+Y=jj?jE+$eNc(t(WZ&gW6* z?=d-Cv$;)YvU5?oiQ7v#deL;3R(SES;89)kwR(9diS;Ncg99jyEBmiL!m`Q>6hu|N z7bg@fwh&HR_F0O#ksPbU-AF<01y|p#6qJ^I3UB2wqdGd93Pov!G@LAPk)kd|T>8G5 z(atjLli#XP%a5lC+RD-lm% zL|Z0kF$u(l0PTs|L)h(M)T?f|*BKw`sPRxq?`3vs$ z`!uT6{V=sl-#%uQzt2u!9%-fBRmKq&mb~hLa9Zs`{BAz0{WbOMquKn&zwfK%UZUZ% z@!vdlRcY?*s2XZA^LS+YGq&m8M`_h0&|URSPQyBbRxc`e%=QabVXbx=o!Grs{l6O? zJ)<-a>QHaLB*VMMaSlAKluBVZCEO0EOxs@gZ zOq#W))3Db{O$gH!lQ#DKE#mE=9%T||b{eEKGN)}+lENQT8;n)vO1jo0)Qm&3+i7VM z+xNvNi+PD|C(KF+Xft#`?r%dD*z9N-DI^{&rY7++tS#@P+fc>tPC$}!>N#pGi7AWs zYFuZBny?r1OVoK^PJwZGqEHP}riwZmP-%7;)f&o6s+~RoWAb1Li;vFSUfz!S82a># zkLPifkfr~7yYxCNV51aPaN@X>0imBYj6`kM*VS!WWIXCwXCaFAV<+P0TECwkUvWY$ zM9r46brO6&Z!Ic`EFk;C?=7rA3vYB=EbXs$g8WZ|XCwXTyH~JNCX`a7)im?{^}H7% z@Qm6AXqAdpY1(0kp)x4#E@9{VOR@6X|L}v1@^9&aj6S7bRW_*Ufhc_)ttiJB=}yY7 zZK^1OI@x+b83o~E*SU32WBTMbUU=8qywdkk4@dixA$T`0g$(?wK6}+R?R=vKlon7) z31M>Upg84aP75T0B|*B`;E8quRDm~DS>$*-8=ScpI_s?#nhRbnJ&5ae`J$ial$o|V zgtPYp=+aAJc!YL6f(SdiIMwm%5H_i%TI(%SVWZV7UGxinj{XWc2c#oUhMO1drZ3wi z7$6bLK6go#q%u?vQc2G1f}G5zEuis9n9V*U6Gdu4^I%i!9;mgx$zXyEE1Nqx)sxk` z{bzzT4d>?h`lruQ8K31P_!NR4s4@|%b|?Z>k4qdUp6W0A(&^>Ttz5!1pYPrIj2bps zA~LRBH8>I5^DW;mc>DR@#29?(MTM%H`lrUQE_N8yf*3i8M={kVXj*RF?3JS;2pN>h z7%TKJhl&{**^5E3I^ATvbHOulnvATnzoCNBNmND?eM4H83u$^qu|3-cWMkkuJ(@?|Qf$#0!{q6<`9=*JFj@oK|BSH3p{qpC@YN>p(O9I)$b!tu9T?(NNJdew|vAAl_cT2gz=LFmElUYfX9xC zeFCwl#)l>pB#|C!NH~^!Po*UQ)tl{kuf*+n)^*eJ?USv`J?~WaVw6sYEu)>2t;kh^ zSs*d$8k{D35nX`FX9GkOjj+LCqqT$r1}UyHgmeaUn&<3ozq3~PR1HpRlEIM&_z}PW z`kDTeMoMJ4Qb>i>eFH~3)hpuEZa4ECR<^hw>x|X?ratEN#DytaCR}`b)e9PKISXnK}XXlO0 zi=BuC16C1)D_l0!en0>1`~Ua(0000)1YPAfjvP!Fc?$I0yO%%!000ISn2h;)P{WV$ zKgrwf`EBTSu2-s@#+`xTW_q~poq>-mh{h0hA}kZhxKMem`lIC2~qRQOE9 zZ{%cXFuMZ_Kih=q@RL!sbR)#BMG!=J;LQnvG(lline3Aww9E<6ERaO_9ToQw zrDIm;+PW8f1?%?65gweNc3HohfO*r;uvDv8!&~Cel6Z9U(p8zWSWAr-UH@Nw%0D@5 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%0Ar{$@$bdoE3f zl+)Mctbu8+cf2|ZjG4pJdASDk3rIyAtP$Vil5r_2J=$ajGZKuZ(6c+qweFga?LnP& z$B9ce!%KXsjXq5SRfiPXswS-iY((b=Cco(GpJg%HS)DkqdeAqujLe#wR1zIZXdXP{ z{tH_2aQ2**7FUgJ)s3-;>kvl}STp0|mAA_vm-_%l7@!GoQnEZj3wN(80xE$qG2{SZ zIMDDIsc1tgV(E1RH4YJVhAu$OSF2jgifS7B-^nsAh0uNyKAFA00E3gW={GK;j+f~h zohq|x14})MW>4@JUmZX(s#ELWl|&eClZa?zxfeI^w{P$BU}>beL!G4Rep3Q0XhY|# zxX-5XZ-SNl@9g(p$H;TR8^d%KYV;`5n>Y_aoKvj|F~_3CwYLL;Zix|MBKN2Gc{jQ^juMuW*km>*!flJ3%IgUW|mpYG8n(BlD(= zYaIL2*Z$588bvRM9b$(#z4=TEXV9>co`${%P2`k7B7-~6>N(aHVi(&XpuZ-pXx}8J%at2o*u<+f=PXCZey2HqS=mzo{*yq2fLiL}*XZ zFZeeaaI|A3?OjRz)Z6fr<&iM{;EgKuw)kJtQnzdIf=Lz8g5bD!>Ia zdi`-EPy%YiqM4s6@-AJS@SL0+)PksAdKJAS&*Ey};Xakz&^z;~{8{gTcy=q3YYClv zekUcJ%~sr=N{K9hX!$%3c3!|GFk!pb^G}j!x71<{nI&QBJUn2ymOZw%1&_z{D#Qo< z2$?==`s+b@XX*7fe2!fCvwA{W`$X9{i$w*gsz#xQ>Y{}mFMjgCOEcAfXwK!9Sp-=Z z9Usy4Z)(`b$B#j76y_&2ic<4cA7>hWa6R18Pl|eS%{#6@R=(Vqn!n)X_uW$mKnCW> z-*d;?76M%O1oGjbbt%9pej*EB^N1_ObjuI1Zh4YOKs@ncY-7 z2q=(=jyXe)u=tLTJ3$7Qzr*VJkHf9v#IOz>-5mCkOJz9fJ6>I0PLu7>h~29nGp1bc z8WiO5fOo6hUK4?w{!!hGwh^=aI}NAkma6QANzyC+1_`9i7#!|pW&6^cChdHbS5!S- zH!Ul?HZGc4CHp;M^{4EU@Olj=Bd$d*Z}RFF+fef(Zm?`MYCk7|d`lPn!FWqnQ`AA@ z4IU0gubhAd5BZBo`LAS86*b{r+#st5zw7dilQAoUbQ}twfKkI+qFZQi#`)^;o%Ru2 z^`^CM5Ohh6b9B)zbsa008rLxbYPmm=%ERRw~S9bM$Y0wsrahI#{GgU}6$>h<`*GDSby=9`8*n?2Yz8fw^8tTIp z^;e{gUf#2x;_5Dd#V9R@4JRhisFtJp!mXO!JUJxBWdF~6fNCq36aD{VKFHf$goW8; zT%)x&-uAMS6^e8$<9x{7((ihXPIjHt$77YB`Rek-MQUO+U^;GZ_nlOJ@z7=OISFHx z#^;?KyE;~DK58rpLf2Vm6az0E_wR4dR9h@t$)d*z2RLDaG_rYAX1k%auZNovLX;45 zJi)jrGgyF$L<3rvz=5k~dK zvFg1cs&u-v(42YX^B;@Ie*C`pjq+UIHhg;~EMiC1aF)SLYc}a-XDSlqQ7+o;qZhq8YUzO!c{RRD4xm>%0;ACrnaQ=0 zK)ht{r19Q3{cHDA6-qWhSpg�IeT~@r?-xXXpbi!xq0(h5wcR4zyG}?;yV7-{-lT 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�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 z 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

    ^$ywdz$5hK=ZG`Afn0K9XJjIQXceAoppq9T@ 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 OVI?aqAtfUr_5T3)u@DIW literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart new file mode 100644 index 000000000000..33d5b340fa5a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart @@ -0,0 +1,1213 @@ +// Copyright 2013 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:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:webview_flutter_wkwebview_example/navigation_decision.dart'; +import 'package:webview_flutter_wkwebview_example/navigation_request.dart'; +import 'package:webview_flutter_wkwebview_example/web_view.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Set to `false` to include all flaky tests in the test run. See also https://github.com/flutter/flutter/issues/86757. + const bool _skipDueToIssue86757 = false; + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('initialUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter.dev/'); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.loadUrl('https://www.google.com/'); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://www.google.com/'); + }, skip: _skipDueToIssue86757); + + testWidgets('loadUrl with headers', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageStarts = StreamController(); + final StreamController pageLoads = StreamController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarts.add(url); + }, + onPageFinished: (String url) { + pageLoads.add(url); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final Map headers = { + 'test_header': 'flutter_test_header' + }; + await controller.loadUrl('https://flutter-header-echo.herokuapp.com/', + headers: headers); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter-header-echo.herokuapp.com/'); + + await pageStarts.stream.firstWhere((String url) => url == currentUrl); + await pageLoads.stream.firstWhere((String url) => url == currentUrl); + + final String content = await controller + .evaluateJavascript('document.documentElement.innerText'); + expect(content.contains('flutter_test_header'), isTrue); + }); + + testWidgets('JavaScriptChannel', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final List messagesReceived = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + // This is the data URL for: '' + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'Echo', + onMessageReceived: (JavascriptMessage message) { + messagesReceived.add(message.message); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(messagesReceived, isEmpty); + // Append a return value "1" in the end will prevent an iOS platform exception. + // See: https://github.com/flutter/flutter/issues/66318#issuecomment-701105380 + // TODO(cyanglaz): remove the workaround "1" in the end when the below issue is fixed. + // https://github.com/flutter/flutter/issues/66318 + await controller.evaluateJavascript('Echo.postMessage("hello");1;'); + expect(messagesReceived, equals(['hello'])); + }); + + testWidgets('resize webview', (WidgetTester tester) async { + final String resizeTest = ''' + + Resize test + + + + + + '''; + final String resizeTestBase64 = + base64Encode(const Utf8Encoder().convert(resizeTest)); + final Completer resizeCompleter = Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + final GlobalKey key = GlobalKey(); + + final WebView webView = WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$resizeTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptChannels: { + JavascriptChannel( + name: 'Resize', + onMessageReceived: (JavascriptMessage message) { + resizeCompleter.complete(true); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 200, + height: 200, + child: webView, + ), + ], + ), + ), + ); + + await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(resizeCompleter.isCompleted, false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 400, + height: 400, + child: webView, + ), + ], + ), + ), + ); + + await resizeCompleter.future; + }); + + testWidgets('set custom userAgent', (WidgetTester tester) async { + final Completer controllerCompleter1 = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent1', + onWebViewCreated: (WebViewController controller) { + controllerCompleter1.complete(controller); + }, + ), + ), + ); + final WebViewController controller1 = await controllerCompleter1.future; + final String customUserAgent1 = await _getUserAgent(controller1); + expect(customUserAgent1, 'Custom_User_Agent1'); + // rebuild the WebView with a different user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent2', + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller1); + expect(customUserAgent2, 'Custom_User_Agent2'); + }); + + testWidgets('use default platform userAgent after webView is rebuilt', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + // Build the webView with no user agent to get the default platform user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String defaultPlatformUserAgent = await _getUserAgent(controller); + // rebuild the WebView with a custom user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ), + ), + ); + final String customUserAgent = await _getUserAgent(controller); + expect(customUserAgent, 'Custom_User_Agent'); + // rebuilds the WebView with no user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller); + expect(customUserAgent2, defaultPlatformUserAgent); + }); + + group('Video playback policy', () { + late String videoTestBase64; + setUpAll(() async { + final ByteData videoData = + await rootBundle.load('assets/sample_video.mp4'); + final String base64VideoData = + base64Encode(Uint8List.view(videoData.buffer)); + final String videoTest = ''' + + Video auto play + + + + + + + '''; + videoTestBase64 = base64Encode(const Utf8Encoder().convert(videoTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolicy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + + testWidgets('Video plays inline when allowsInlineMediaPlayback is true', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: true, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.evaluateJavascript('isFullScreen();'); + expect(fullScreen, _webviewBool(false)); + }); + + testWidgets( + 'Video plays full screen when allowsInlineMediaPlayback is false', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: false, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.evaluateJavascript('isFullScreen();'); + expect(fullScreen, _webviewBool(true)); + }); + }); + + group('Audio playback policy', () { + late String audioTestBase64; + setUpAll(() async { + final ByteData audioData = + await rootBundle.load('assets/sample_audio.ogg'); + final String base64AudioData = + base64Encode(Uint8List.view(audioData.buffer)); + final String audioTest = ''' + + Audio auto play + + + + + + + '''; + audioTestBase64 = base64Encode(const Utf8Encoder().convert(audioTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageStarted = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolocy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageStarted = Completer(); + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.evaluateJavascript('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + }); + + testWidgets('getTitle', (WidgetTester tester) async { + final String getTitleTest = ''' + + Some title + + + + + '''; + final String getTitleTestBase64 = + base64Encode(const Utf8Encoder().convert(getTitleTest)); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$getTitleTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + final String? title = await controller.getTitle(); + expect(title, 'Some title'); + }); + + group('Programmatic Scroll', () { + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +

    + + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + // Get the initial position; this ensures that scrollTo is actually + // changing something, but also gives the native view's scroll position + // time to settle. + expect(scrollPosX, isNot(X_SCROLL)); + expect(scrollPosX, isNot(Y_SCROLL)); + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL); + expect(scrollPosY, Y_SCROLL); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL * 2); + expect(scrollPosY, Y_SCROLL * 2); + }); + }); + + group('NavigationDelegate', () { + final String blankPage = ""; + final String blankPageEncoded = 'data:text/html;charset=utf-8;base64,' + + base64Encode(const Utf8Encoder().convert(blankPage)); + + testWidgets('can allow requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('location.href = "https://www.google.com/"'); + + await pageLoads.stream.first; // Wait for the next page load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://www.google.com/'); + }); + + testWidgets('onWebResourceError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://www.notawebsite..com', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + final WebResourceError error = await errorCompleter.future; + expect(error, isNotNull); + + if (Platform.isIOS) { + expect(error.domain, isNotNull); + expect(error.failingUrl, isNull); + } else if (Platform.isAndroid) { + expect(error.errorType, isNotNull); + expect(error.failingUrl?.startsWith('https://www.notawebsite..com'), + isTrue); + } + }); + + testWidgets('onWebResourceError is not called with valid url', + (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + + testWidgets( + 'onWebResourceError only called for main frame', + (WidgetTester tester) async { + final String iframeTest = ''' + + + + WebResourceError test + + + + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframeTest)); + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,$iframeTestBase64', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }, + ); + + testWidgets('can block requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('location.href = "https://www.youtube.com/"'); + + // There should never be any second page load, since our new URL is + // blocked. Still wait for a potential page change for some time in order + // to give the test a chance to fail. + await pageLoads.stream.first + .timeout(const Duration(milliseconds: 500), onTimeout: () => ''); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, isNot(contains('youtube.com'))); + }); + + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate); + return decision; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('location.href = "https://www.google.com"'); + + await pageLoads.stream.first; // Wait for second page to load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://www.google.com/'); + }); + }); + + testWidgets('launches with gestureNavigationEnabled on iOS', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 300, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://flutter.dev/', + gestureNavigationEnabled: true, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter.dev/'); + }); + + testWidgets('target _blank opens in same window', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller + .evaluateJavascript('window.open("https://flutter.dev/", "_blank")'); + await pageLoaded.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter.dev/'); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets( + 'can open new window and go back', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(); + }, + initialUrl: 'https://flutter.dev', + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + expect(controller.currentUrl(), completion('https://flutter.dev/')); + await pageLoaded.future; + pageLoaded = Completer(); + + await controller + .evaluateJavascript('window.open("https://www.google.com/")'); + await pageLoaded.future; + pageLoaded = Completer(); + expect(controller.currentUrl(), completion('https://www.google.com/')); + + expect(controller.canGoBack(), completion(true)); + await controller.goBack(); + await pageLoaded.future; + expect(controller.currentUrl(), completion('https://flutter.dev/')); + }, + skip: _skipDueToIssue86757, + ); +} + +// JavaScript booleans evaluate to different string values on Android and iOS. +// This utility method returns the string boolean value of the current platform. +String _webviewBool(bool value) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return value ? '1' : '0'; + } + return value ? 'true' : 'false'; +} + +/// Returns the value used for the HTTP User-Agent: request header in subsequent HTTP requests. +Future _getUserAgent(WebViewController controller) async { + return _evaluateJavascript(controller, 'navigator.userAgent;'); +} + +Future _evaluateJavascript( + WebViewController controller, String js) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return await controller.evaluateJavascript(js); + } + return jsonDecode(await controller.evaluateJavascript(js)); +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000000..8d4492f977ad --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000000..e8efba114687 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000000..399e9340e6f6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile new file mode 100644 index 000000000000..66509fcae284 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile @@ -0,0 +1,45 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + target 'RunnerTests' do + inherit! :search_paths + + # Matches test_spec dependency. + pod 'OCMock', '3.5' + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..62428d041adf --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,722 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */; }; + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; + 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 9D26F6F82D91F92CC095EBA9 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B845D8FBDE0AAD6BE1A0386 /* libPods-Runner.a */; }; + D9A9D48F1A75E5C682944DDD /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 27CC950C9005575711528C12 /* libPods-RunnerTests.a */; }; + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F76266057800028CB91 /* FLTWebViewUITests.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; + F7151F79266057800028CB91 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 127772EEA7782174BE0D74B5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 27CC950C9005575711528C12 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWKNavigationDelegateTests.m; sourceTree = ""; }; + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 68BDCAED23C3F7CB00D9C032 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewTests.m; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 8B845D8FBDE0AAD6BE1A0386 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C475C484BD510DD9CB2E403C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + E14113434CCE6D3186B5CBC3 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F674B2A05DAC369B4FF27850 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F7151F74266057800028CB91 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F7151F76266057800028CB91 /* FLTWebViewUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewUITests.m; sourceTree = ""; }; + F7151F78266057800028CB91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 68BDCAE623C3F7CB00D9C032 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D9A9D48F1A75E5C682944DDD /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9D26F6F82D91F92CC095EBA9 /* libPods-Runner.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F71266057800028CB91 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */, + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */, + 68BDCAED23C3F7CB00D9C032 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */, + F7151F75266057800028CB91 /* RunnerUITests */, + 97C146EF1CF9000F007C117D /* Products */, + C6FFB52F5C2B8A41A7E39DE2 /* Pods */, + B6736FC417BDCCDA377E779D /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */, + F7151F74266057800028CB91 /* RunnerUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 97C146F21CF9000F007C117D /* main.m */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + B6736FC417BDCCDA377E779D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8B845D8FBDE0AAD6BE1A0386 /* libPods-Runner.a */, + 27CC950C9005575711528C12 /* libPods-RunnerTests.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + C6FFB52F5C2B8A41A7E39DE2 /* Pods */ = { + isa = PBXGroup; + children = ( + 127772EEA7782174BE0D74B5 /* Pods-Runner.debug.xcconfig */, + C475C484BD510DD9CB2E403C /* Pods-Runner.release.xcconfig */, + F674B2A05DAC369B4FF27850 /* Pods-RunnerTests.debug.xcconfig */, + E14113434CCE6D3186B5CBC3 /* Pods-RunnerTests.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + F7151F75266057800028CB91 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + F7151F76266057800028CB91 /* FLTWebViewUITests.m */, + F7151F78266057800028CB91 /* Info.plist */, + ); + path = RunnerUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 53FD4CBDD9756D74B5A3B4C1 /* [CP] Check Pods Manifest.lock */, + 68BDCAE523C3F7CB00D9C032 /* Sources */, + 68BDCAE623C3F7CB00D9C032 /* Frameworks */, + 68BDCAE723C3F7CB00D9C032 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = webview_flutter_exampleTests; + productReference = 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + B71376B4FB8384EF9D5F3F84 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; + F7151F73266057800028CB91 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + F7151F70266057800028CB91 /* Sources */, + F7151F71266057800028CB91 /* Frameworks */, + F7151F72266057800028CB91 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F7151F7A266057800028CB91 /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = F7151F74266057800028CB91 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + DefaultBuildSystemTypeForWorkspace = Original; + LastUpgradeCheck = 1030; + ORGANIZATIONNAME = "The Flutter Authors"; + TargetAttributes = { + 68BDCAE823C3F7CB00D9C032 = { + ProvisioningStyle = Automatic; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + }; + F7151F73266057800028CB91 = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */, + F7151F73266057800028CB91 /* RunnerUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 68BDCAE723C3F7CB00D9C032 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F72266057800028CB91 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; + }; + 53FD4CBDD9756D74B5A3B4C1 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; + B71376B4FB8384EF9D5F3F84 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 68BDCAE523C3F7CB00D9C032 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */, + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */, + 97C146F31CF9000F007C117D /* main.m in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F70266057800028CB91 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */; + }; + F7151F7A266057800028CB91 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F7151F79266057800028CB91 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 68BDCAF023C3F7CB00D9C032 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F674B2A05DAC369B4FF27850 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 68BDCAF123C3F7CB00D9C032 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E14113434CCE6D3186B5CBC3 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + F7151F7C266057800028CB91 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + F7151F7D266057800028CB91 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 68BDCAF023C3F7CB00D9C032 /* Debug */, + 68BDCAF123C3F7CB00D9C032 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7151F7C266057800028CB91 /* Debug */, + F7151F7D266057800028CB91 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..d7453a8ce862 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h new file mode 100644 index 000000000000..0681d288bb70 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h @@ -0,0 +1,10 @@ +// Copyright 2013 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 +#import + +@interface AppDelegate : FlutterAppDelegate + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m new file mode 100644 index 000000000000..30b87969f44a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m @@ -0,0 +1,17 @@ +// Copyright 2013 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. + +#include "AppDelegate.h" +#include "GeneratedPluginRegistrant.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [GeneratedPluginRegistrant registerWithRegistry:self]; + // Override point for customization after application launch. + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..d36b1fab2d9d --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..3d43d11e66f4de3da27ed045ca4fe38ad8b48094 GIT binary patch literal 11112 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 P-1+_A+?hGkL;m(J7kc&K literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..28c6bf03016f6c994b70f38d1b7346e5831b531f GIT binary patch literal 564 zcmV-40?Yl0P)Px$?ny*JR5%f>l)FnDQ543{x%ZCiu33$Wg!pQFfT_}?5Q|_VSlIbLC`dpoMXL}9 zHfd9&47Mo(7D231gb+kjFxZHS4-m~7WurTH&doVX2KI5sU4v(sJ1@T9eCIKPjsqSr z)C01LsCxk=72-vXmX}CQD#BD;Cthymh&~=f$Q8nn0J<}ZrusBy4PvRNE}+1ceuj8u z0mW5k8fmgeLnTbWHGwfKA3@PdZxhn|PypR&^p?weGftrtCbjF#+zk_5BJh7;0`#Wr zgDpM_;Ax{jO##IrT`Oz;MvfwGfV$zD#c2xckpcXC6oou4ML~ezCc2EtnsQTB4tWNg z?4bkf;hG7IMfhgNI(FV5Gs4|*GyMTIY0$B=_*mso9Ityq$m^S>15>-?0(zQ<8Qy<_TjHE33(?_M8oaM zyc;NxzRVK@DL6RJnX%U^xW0Gpg(lXp(!uK1v0YgHjs^ZXSQ|m#lV7ip7{`C_J2TxPmfw%h$|%acrYHt)Re^PB%O&&=~a zhS(%I#+V>J-vjIib^<+s%ludY7y^C(P8nmqn9fp!i+?vr`bziDE=bx`%2W#Xyrj|i z!XQ4v1%L`m{7KT7q+LZNB^h8Ha2e=`Wp65^0;J00)_^G=au=8Yo;1b`CV&@#=jIBo zjN^JNVfYSs)+kDdGe7`1&8!?MQYKS?DuHZf3iogk_%#9E|5S zWeHrmAo>P;ejX7mwq#*}W25m^ZI+{(Z8fI?4jM_fffY0nok=+88^|*_DwcW>mR#e+ zX$F_KMdb6sRz!~7KkyN0G(3XQ+;z3X%PZ4gh;n-%62U<*VUKNv(D&Q->Na@Xb&u5Q3`3DGf+a8O5x7c#7+R+EAYl@R5us)CIw z7sT@_y~Ao@uL#&^LIh&QceqiT^+lb0YbFZt_SHOtWA%mgPEKVNvVgCsXy{5+zl*X8 zCJe)Q@y>wH^>l4;h1l^Y*9%-23TSmE>q5nI@?mt%n;Sj4Qq`Z+ib)a*a^cJc%E9^J zB;4s+K@rARbcBLT5P=@r;IVnBMKvT*)ew*R;&8vu%?Z&S>s?8?)3*YawM0P4!q$Kv zMmKh3lgE~&w&v%wVzH3Oe=jeNT=n@Y6J6TdHWTjXfX~-=1A1Bw`EW8rn}MqeI34nh zexFeA?&C3B2(E?0{drE@DA2pu(A#ElY&6el60Rn|Qpn-FkfQ8M93AfWIr)drgDFEU zghdWK)^71EWCP(@(=c4kfH1Y(4iugD4fve6;nSUpLT%!)MUHs1!zJYy4y||C+SwQ! z)KM&$7_tyM`sljP2fz6&Z;jxRn{Wup8IOUx8D4uh&(=O zx-7$a;U><*5L^!%xRlw)vAbh;sdlR||& ze}8_8%)c2Fwy=F&H|LM+p{pZB5DKTx>Y?F1N%BlZkXf!}JeGuMZk~LPi7{cidvUGB zAJ4LVeNV%XO>LTrklB#^-;8nb;}6l;1oW&WS=Mz*Az!4cqqQzbOSFq`$Q%PfD7srM zpKgP-D_0XPTRX*hAqeq0TDkJ;5HB1%$3Np)99#16c{ zJImlNL(npL!W|Gr_kxl1GVmF5&^$^YherS7+~q$p zt}{a=*RiD2Ikv6o=IM1kgc7zqpaZ;OB)P!1zz*i3{U()Dq#jG)egvK}@uFLa`oyWZ zf~=MV)|yJn`M^$N%ul5);JuQvaU1r2wt(}J_Qgyy`qWQI`hEeRX0uC@c1(dQ2}=U$ tNIIaX+dr)NRWXcxoR{>fqI{SF_dm1Ylv~=3YHI)h002ovPDHLkV1g(pWS;;4 literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f091b6b0bca859a3f474b03065bef75ba58a9e4c GIT binary patch literal 1588 zcmV-42Fv-0P)C1SqPt}wig>|5Crh^=oyX$BK<}M8eLU3e2hGT;=G|!_SP)7zNI6fqUMB=)y zRAZ>eDe#*r`yDAVgB_R*LB*MAc)8(b{g{9McCXW!lq7r(btRoB9!8B-#AI6JMb~YFBEvdsV)`mEQO^&#eRKx@b&x- z5lZm*!WfD8oCLzfHGz#u7sT0^VLMI1MqGxF^v+`4YYnVYgk*=kU?HsSz{v({E3lb9 z>+xILjBN)t6`=g~IBOelGQ(O990@BfXf(DRI5I$qN$0Gkz-FSc$3a+2fX$AedL4u{ z4V+5Ong(9LiGcIKW?_352sR;LtDPmPJXI{YtT=O8=76o9;*n%_m|xo!i>7$IrZ-{l z-x3`7M}qzHsPV@$v#>H-TpjDh2UE$9g6sysUREDy_R(a)>=eHw-WAyfIN z*qb!_hW>G)Tu8nSw9yn#3wFMiLcfc4pY0ek1}8(NqkBR@t4{~oC>ryc-h_ByH(Cg5 z>ao-}771+xE3um9lWAY1FeQFxowa1(!J(;Jg*wrg!=6FdRX+t_<%z&d&?|Bn){>zm zZQj(aA_HeBY&OC^jj*)N`8fa^ePOU72VpInJoI1?`ty#lvlNzs(&MZX+R%2xS~5Kh zX*|AU4QE#~SgPzOXe9>tRj>hjU@c1k5Y_mW*Jp3fI;)1&g3j|zDgC+}2Q_v%YfDax z!?umcN^n}KYQ|a$Lr+51Nf9dkkYFSjZZjkma$0KOj+;aQ&721~t7QUKx61J3(P4P1 zstI~7-wOACnWP4=8oGOwz%vNDqD8w&Q`qcNGGrbbf&0s9L0De{4{mRS?o0MU+nR_! zrvshUau0G^DeMhM_v{5BuLjb#Hh@r23lDAk8oF(C+P0rsBpv85EP>4CVMx#04MOfG z;P%vktHcXwTj~+IE(~px)3*MY77e}p#|c>TD?sMatC0Tu4iKKJ0(X8jxQY*gYtxsC z(zYC$g|@+I+kY;dg_dE>scBf&bP1Nc@Hz<3R)V`=AGkc;8CXqdi=B4l2k|g;2%#m& z*jfX^%b!A8#bI!j9-0Fi0bOXl(-c^AB9|nQaE`*)Hw+o&jS9@7&Gov#HbD~#d{twV zXd^Tr^mWLfFh$@Dr$e;PBEz4(-2q1FF0}c;~B5sA}+Q>TOoP+t>wf)V9Iy=5ruQa;z)y zI9C9*oUga6=hxw6QasLPnee@3^Rr*M{CdaL5=R41nLs(AHk_=Y+A9$2&H(B7!_pURs&8aNw7?`&Z&xY_Ye z)~D5Bog^td-^QbUtkTirdyK^mTHAOuptDflut!#^lnKqU md>ggs(5nOWAqO?umG&QVYK#ibz}*4>0000U6E9hRK9^#O7(mu>ETqrXGsduA8$)?`v2seloOCza43C{NQ$$gAOH**MCn0Q?+L7dl7qnbRdqZ8LSVp1ItDxhxD?t@5_yHg6A8yI zC*%Wgg22K|8E#!~cTNYR~@Y9KepMPrrB8cABapAFa=`H+UGhkXUZV1GnwR1*lPyZ;*K(i~2gp|@bzp8}og7e*#% zEnr|^CWdVV!-4*Y_7rFvlww2Ze+>j*!Z!pQ?2l->4q#nqRu9`ELo6RMS5=br47g_X zRw}P9a7RRYQ%2Vsd0Me{_(EggTnuN6j=-?uFS6j^u69elMypu?t>op*wBx<=Wx8?( ztpe^(fwM6jJX7M-l*k3kEpWOl_Vk3@(_w4oc}4YF4|Rt=2V^XU?#Yz`8(e?aZ@#li0n*=g^qOcVpd-Wbok=@b#Yw zqn8u9a)z>l(1kEaPYZ6hwubN6i<8QHgsu0oE) ziJ(p;Wxm>sf!K+cw>R-(^Y2_bahB+&KI9y^);#0qt}t-$C|Bo71lHi{_+lg#f%RFy z0um=e3$K3i6K{U_4K!EX?F&rExl^W|G8Z8;`5z-k}OGNZ0#WVb$WCpQu-_YsiqKP?BB# vzVHS-CTUF4Ozn5G+mq_~Qqto~ahA+K`|lyv3(-e}00000NkvXXu0mjfd`9t{ literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d0ef06e7edb86cdfe0d15b4b0d98334a86163658 GIT binary patch literal 1716 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 literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f9ed8f5cee1c98386d13b17e89f719e83555b2 GIT binary patch literal 1895 zcmV-t2blPYP)FQtfgmafE#=YDCq`qUBt#QpG%*H6QHY765~R=q zZ6iudfM}q!Pz#~9JgOi8QJ|DSu?1-*(kSi1K4#~5?#|rh?sS)(-JQqX*}ciXJ56_H zdw=^s_srbAdqxlvGyrgGet#6T7_|j;95sL%MtM;q86vOxKM$f#puR)Bjv9Zvz9-di zXOTSsZkM83)E9PYBXC<$6(|>lNLVBb&&6y{NByFCp%6+^ALR@NCTse_wqvNmSWI-m z!$%KlHFH2omF!>#%1l3LTZg(s7eof$7*xB)ZQ0h?ejh?Ta9fDv59+u#MokW+1t8Zb zgHv%K(u9G^Lv`lh#f3<6!JVTL3(dCpxHbnbA;kKqQyd1~^Xe0VIaYBSWm6nsr;dFj z4;G-RyL?cYgsN1{L4ZFFNa;8)Rv0fM0C(~Tkit94 zz#~A)59?QjD&pAPSEQ)p8gP|DS{ng)j=2ux)_EzzJ773GmQ_Cic%3JJhC0t2cx>|v zJcVusIB!%F90{+}8hG3QU4KNeKmK%T>mN57NnCZ^56=0?&3@!j>a>B43pi{!u z7JyDj7`6d)qVp^R=%j>UIY6f+3`+qzIc!Y_=+uN^3BYV|o+$vGo-j-Wm<10%A=(Yk^beI{t%ld@yhKjq0iNjqN4XMGgQtbKubPM$JWBz}YA65k%dm*awtC^+f;a-x4+ddbH^7iDWGg&N0n#MW{kA|=8iMUiFYvMoDY@sPC#t$55gn6ykUTPAr`a@!(;np824>2xJthS z*ZdmT`g5-`BuJs`0LVhz+D9NNa3<=6m;cQLaF?tCv8)zcRSh66*Z|vXhG@$I%U~2l z?`Q zykI#*+rQ=z6Jm=Bui-SfpDYLA=|vzGE(dYm=OC8XM&MDo7ux4UF1~0J1+i%aCUpRe zt3L_uNyQ*cE(38Uy03H%I*)*Bh=Lb^Xj3?I^Hnbeq72(EOK^Y93CNp*uAA{5Lc=ky zx=~RKa4{iTm{_>_vSCm?$Ej=i6@=m%@VvAITnigVg{&@!7CDgs908761meDK5azA} z4?=NOH|PdvabgJ&fW2{Mo$Q0CcD8Qc84%{JPYt5EiG{MdLIAeX%T=D7NIP4%Hw}p9 zg)==!2Lbp#j{u_}hMiao9=!VSyx0gHbeCS`;q&vzeq|fs`y&^X-lso(Ls@-706qmA z7u*T5PMo_w3{se1t2`zWeO^hOvTsohG_;>J0wVqVe+n)AbQCx)yh9;w+J6?NF5Lmo zecS@ieAKL8%bVd@+-KT{yI|S}O>pYckUFs;ry9Ow$CD@ztz5K-*D$^{i(_1llhSh^ zEkL$}tsQt5>QA^;QgjgIfBDmcOgi5YDyu?t6vSnbp=1+@6D& z5MJ}B8q;bRlVoxasyhcUF1+)o`&3r0colr}QJ3hcSdLu;9;td>kf@Tcn<@9sIx&=m z;AD;SCh95=&p;$r{Xz3iWCO^MX83AGJ(yH&eTXgv|0=34#-&WAmw{)U7OU9!Wz^!7 zZ%jZFi@JR;>Mhi7S>V7wQ176|FdW2m?&`qa(ScO^CFPR80HucLHOTy%5s*HR0^8)i h0WYBP*#0Ks^FNSabJA*5${_#%002ovPDHLkV1oKhTl@e3 literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..75b2d164a5a98e212cca15ea7bf2ab5de5108680 GIT binary patch literal 3831 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 literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..c4df70d39da7941ef3f6dcb7f06a192d8dcb308d GIT binary patch literal 1888 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 iW8Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 000000000000..89c2725b70f1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000000..f2e259c7c939 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000000..f3c28516fb38 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist new file mode 100644 index 000000000000..a810c5a172c0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + webview_flutter_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m new file mode 100644 index 000000000000..f97b9ef5c8a1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m @@ -0,0 +1,13 @@ +// Copyright 2013 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 +#import +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m new file mode 100644 index 000000000000..eb6d1543ec07 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m @@ -0,0 +1,41 @@ +// Copyright 2013 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 Flutter; +@import XCTest; +@import webview_flutter_wkwebview; + +// OCMock library doesn't generate a valid modulemap. +#import + +@interface FLTWKNavigationDelegateTests : XCTestCase + +@property(strong, nonatomic) FlutterMethodChannel *mockMethodChannel; +@property(strong, nonatomic) FLTWKNavigationDelegate *navigationDelegate; + +@end + +@implementation FLTWKNavigationDelegateTests + +- (void)setUp { + self.mockMethodChannel = OCMClassMock(FlutterMethodChannel.class); + self.navigationDelegate = + [[FLTWKNavigationDelegate alloc] initWithChannel:self.mockMethodChannel]; +} + +- (void)testWebViewWebContentProcessDidTerminateCallsRecourseErrorChannel { + if (@available(iOS 9.0, *)) { + // `webViewWebContentProcessDidTerminate` is only available on iOS 9.0 and above. + WKWebView *webview = OCMClassMock(WKWebView.class); + [self.navigationDelegate webViewWebContentProcessDidTerminate:webview]; + OCMVerify([self.mockMethodChannel + invokeMethod:@"onWebResourceError" + arguments:[OCMArg checkWithBlock:^BOOL(NSDictionary *args) { + XCTAssertEqualObjects(args[@"errorType"], @"webContentProcessTerminated"); + return true; + }]]); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m new file mode 100644 index 000000000000..631c4a105063 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m @@ -0,0 +1,91 @@ +// Copyright 2013 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 Flutter; +@import XCTest; +@import webview_flutter_wkwebview; + +// OCMock library doesn't generate a valid modulemap. +#import + +static bool feq(CGFloat a, CGFloat b) { return fabs(b - a) < FLT_EPSILON; } + +@interface FLTWebViewTests : XCTestCase + +@property(strong, nonatomic) NSObject *mockBinaryMessenger; + +@end + +@implementation FLTWebViewTests + +- (void)setUp { + [super setUp]; + self.mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); +} + +- (void)testCanInitFLTWebViewController { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTAssertNotNil(controller); +} + +- (void)testCanInitFLTWebViewFactory { + FLTWebViewFactory *factory = + [[FLTWebViewFactory alloc] initWithMessenger:self.mockBinaryMessenger]; + XCTAssertNotNil(factory); +} + +- (void)webViewContentInsetBehaviorShouldBeNeverOnIOS11 { + if (@available(iOS 11, *)) { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + UIView *view = controller.view; + XCTAssertTrue([view isKindOfClass:WKWebView.class]); + WKWebView *webView = (WKWebView *)view; + XCTAssertEqual(webView.scrollView.contentInsetAdjustmentBehavior, + UIScrollViewContentInsetAdjustmentNever); + } +} + +- (void)testWebViewScrollIndicatorAticautomaticallyAdjustsScrollIndicatorInsetsShouldbeNoOnIOS13 { + if (@available(iOS 13, *)) { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + UIView *view = controller.view; + XCTAssertTrue([view isKindOfClass:WKWebView.class]); + WKWebView *webView = (WKWebView *)view; + XCTAssertFalse(webView.scrollView.automaticallyAdjustsScrollIndicatorInsets); + } +} + +- (void)testContentInsetsSumAlwaysZeroAfterSetFrame { + FLTWKWebView *webView = [[FLTWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 400)]; + webView.scrollView.contentInset = UIEdgeInsetsMake(0, 0, 300, 0); + XCTAssertFalse(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + webView.frame = CGRectMake(0, 0, 300, 200); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + XCTAssertTrue(CGRectEqualToRect(webView.frame, CGRectMake(0, 0, 300, 200))); + + if (@available(iOS 11, *)) { + // After iOS 11, we need to make sure the contentInset compensates the adjustedContentInset. + UIScrollView *partialMockScrollView = OCMPartialMock(webView.scrollView); + UIEdgeInsets insetToAdjust = UIEdgeInsetsMake(0, 0, 300, 0); + OCMStub(partialMockScrollView.adjustedContentInset).andReturn(insetToAdjust); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + webView.frame = CGRectMake(0, 0, 300, 100); + XCTAssertTrue(feq(webView.scrollView.contentInset.bottom, -insetToAdjust.bottom)); + XCTAssertTrue(CGRectEqualToRect(webView.frame, CGRectMake(0, 0, 300, 100))); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m new file mode 100644 index 000000000000..d193be745972 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m @@ -0,0 +1,101 @@ +// Copyright 2013 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 XCTest; +@import os.log; + +@interface FLTWebViewUITests : XCTestCase +@property(nonatomic, strong) XCUIApplication* app; +@end + +@implementation FLTWebViewUITests + +- (void)setUp { + self.continueAfterFailure = NO; + + self.app = [[XCUIApplication alloc] init]; + [self.app launch]; +} + +- (void)testUserAgent { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* userAgent = app.buttons[@"Show user agent"]; + if (![userAgent waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Show user agent"); + } + NSPredicate* userAgentPredicate = + [NSPredicate predicateWithFormat:@"label BEGINSWITH 'User Agent: Mozilla/5.0 (iPhone; '"]; + XCUIElement* userAgentPopUp = [app.otherElements elementMatchingPredicate:userAgentPredicate]; + XCTAssertFalse(userAgentPopUp.exists); + [userAgent tap]; + if (![userAgentPopUp waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find user agent pop up"); + } +} + +- (void)testCache { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* clearCache = app.buttons[@"Clear cache"]; + if (![clearCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Clear cache"); + } + [clearCache tap]; + + [menu tap]; + + XCUIElement* listCache = app.buttons[@"List cache"]; + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* emptyCachePopup = app.otherElements[@"{\"cacheKeys\":[],\"localStorage\":{}}"]; + if (![emptyCachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find empty cache pop up"); + } + + [menu tap]; + XCUIElement* addCache = app.buttons[@"Add to cache"]; + if (![addCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Add to cache"); + } + [addCache tap]; + [menu tap]; + + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* cachePopup = + app.otherElements[@"{\"cacheKeys\":[\"test_caches_entry\"],\"localStorage\":{\"test_" + @"localStorage\":\"dummy_entry\"}}"]; + if (![cachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find cache pop up"); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart new file mode 100644 index 000000000000..a953e062ded5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart @@ -0,0 +1,344 @@ +// Copyright 2013 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. + +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'navigation_decision.dart'; +import 'navigation_request.dart'; +import 'web_view.dart'; + +void main() { + runApp(MaterialApp(home: _WebViewExample())); +} + +const String kNavigationExamplePage = ''' + +Navigation Delegate Example + +

    +The navigation delegate is set to block navigation to the youtube website. +

    +
    + + +'''; + +class _WebViewExample extends StatefulWidget { + const _WebViewExample({Key? key}) : super(key: key); + + @override + _WebViewExampleState createState() => _WebViewExampleState(); +} + +class _WebViewExampleState extends State<_WebViewExample> { + final Completer _controller = + Completer(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter WebView example'), + // This drop down menu demonstrates that Flutter widgets can be shown over the web view. + actions: [ + _NavigationControls(_controller.future), + _SampleMenu(_controller.future), + ], + ), + // We're using a Builder here so we have a context that is below the Scaffold + // to allow calling Scaffold.of(context) so we can show a snackbar. + body: Builder(builder: (context) { + return WebView( + initialUrl: 'https://flutter.dev', + onWebViewCreated: (WebViewController controller) { + _controller.complete(controller); + }, + javascriptChannels: _createJavascriptChannels(context), + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + if (request.url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $request}'); + return NavigationDecision.prevent; + } + print('allowing navigation to $request'); + return NavigationDecision.navigate; + }, + ); + }), + floatingActionButton: favoriteButton(), + ); + } + + Widget favoriteButton() { + return FutureBuilder( + future: _controller.future, + builder: (BuildContext context, + AsyncSnapshot controller) { + if (controller.hasData) { + return FloatingActionButton( + onPressed: () async { + final String url = (await controller.data!.currentUrl())!; + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar(content: Text('Favorited $url')), + ); + }, + child: const Icon(Icons.favorite), + ); + } + return Container(); + }); + } +} + +Set _createJavascriptChannels(BuildContext context) { + return { + JavascriptChannel( + name: 'Snackbar', + onMessageReceived: (JavascriptMessage message) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message.message))); + }), + }; +} + +enum _MenuOptions { + showUserAgent, + listCookies, + clearCookies, + addToCache, + listCache, + clearCache, + navigationDelegate, +} + +class _SampleMenu extends StatelessWidget { + _SampleMenu(this.controller); + + final Future controller; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: controller, + builder: + (BuildContext context, AsyncSnapshot controller) { + return PopupMenuButton<_MenuOptions>( + onSelected: (_MenuOptions value) { + switch (value) { + case _MenuOptions.showUserAgent: + _onShowUserAgent(controller.data!, context); + break; + case _MenuOptions.listCookies: + _onListCookies(controller.data!, context); + break; + case _MenuOptions.clearCookies: + _onClearCookies(controller.data!, context); + break; + case _MenuOptions.addToCache: + _onAddToCache(controller.data!, context); + break; + case _MenuOptions.listCache: + _onListCache(controller.data!, context); + break; + case _MenuOptions.clearCache: + _onClearCache(controller.data!, context); + break; + case _MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data!, context); + break; + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem<_MenuOptions>( + value: _MenuOptions.showUserAgent, + child: const Text('Show user agent'), + enabled: controller.hasData, + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCookies, + child: Text('List cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCookies, + child: Text('Clear cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.addToCache, + child: Text('Add to cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCache, + child: Text('List cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCache, + child: Text('Clear cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), + ], + ); + }, + ); + } + + void _onShowUserAgent( + WebViewController controller, BuildContext context) async { + // Send a message with the user agent string to the Snackbar JavaScript channel we registered + // with the WebView. + await controller.evaluateJavascript( + 'Snackbar.postMessage("User Agent: " + navigator.userAgent);'); + } + + void _onListCookies( + WebViewController controller, BuildContext context) async { + final String cookies = + await controller.evaluateJavascript('document.cookie'); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Cookies:'), + _getCookieList(cookies), + ], + ), + )); + } + + void _onAddToCache(WebViewController controller, BuildContext context) async { + await controller.evaluateJavascript( + 'caches.open("test_caches_entry"); localStorage["test_localStorage"] = "dummy_entry";'); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Added a test entry to cache.'), + )); + } + + void _onListCache(WebViewController controller, BuildContext context) async { + await controller.evaluateJavascript('caches.keys()' + '.then((cacheKeys) => JSON.stringify({"cacheKeys" : cacheKeys, "localStorage" : localStorage}))' + '.then((caches) => Snackbar.postMessage(caches))'); + } + + void _onClearCache(WebViewController controller, BuildContext context) async { + await controller.clearCache(); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text("Cache cleared."), + )); + } + + void _onClearCookies( + WebViewController controller, BuildContext context) async { + final bool hadCookies = await WebView.platform.clearCookies(); + String message = 'There were cookies. Now, they are gone!'; + if (!hadCookies) { + message = 'There are no cookies.'; + } + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + )); + } + + void _onNavigationDelegateExample( + WebViewController controller, BuildContext context) async { + final String contentBase64 = + base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); + await controller.loadUrl('data:text/html;base64,$contentBase64'); + } + + Widget _getCookieList(String cookies) { + if (cookies == null || cookies == '""') { + return Container(); + } + final List cookieList = cookies.split(';'); + final Iterable cookieWidgets = + cookieList.map((String cookie) => Text(cookie)); + return Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: cookieWidgets.toList(), + ); + } +} + +class _NavigationControls extends StatelessWidget { + const _NavigationControls(this._webViewControllerFuture) + : assert(_webViewControllerFuture != null); + + final Future _webViewControllerFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _webViewControllerFuture, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final bool webViewReady = + snapshot.connectionState == ConnectionState.done; + final WebViewController? controller = snapshot.data; + if (controller == null) return Container(); + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller.canGoBack()) { + await controller.goBack(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar(content: Text("No back history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller.canGoForward()) { + await controller.goForward(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar( + content: Text("No forward history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.replay), + onPressed: !webViewReady + ? null + : () { + controller.reload(); + }, + ), + ], + ); + }, + ); + } +} + +/// Callback type for handling messages sent from Javascript running in a web view. +typedef void JavascriptMessageHandler(JavascriptMessage message); diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart new file mode 100644 index 000000000000..d8178acd8096 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart @@ -0,0 +1,12 @@ +// Copyright 2013 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. + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart new file mode 100644 index 000000000000..c1ff8dc5a690 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart @@ -0,0 +1,19 @@ +// Copyright 2013 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. + +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({required this.url, required this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart new file mode 100644 index 000000000000..ddb8e9b0f14f --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart @@ -0,0 +1,592 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +import 'navigation_decision.dart'; +import 'navigation_request.dart'; + +/// Optional callback invoked when a web view is first created. [controller] is +/// the [WebViewController] for the created web view. +typedef void WebViewCreatedCallback(WebViewController controller); + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef FutureOr NavigationDelegate( + NavigationRequest navigation); + +/// Signature for when a [WebView] has started loading a page. +typedef void PageStartedCallback(String url); + +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + +/// Signature for when a [WebView] is loading a page. +typedef void PageLoadingCallback(int progress); + +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + +/// A web view widget for showing html content. +/// +/// There is a known issue that on iOS 13.4 and 13.5, other flutter widgets covering +/// the `WebView` is not able to block the `WebView` from receiving touch events. +/// See https://github.com/flutter/flutter/issues/53490. +class WebView extends StatefulWidget { + /// Creates a new web view. + /// + /// The web view can be controlled using a `WebViewController` that is passed to the + /// `onWebViewCreated` callback once the web view is created. + /// + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. + const WebView({ + Key? key, + this.onWebViewCreated, + this.initialUrl, + this.javascriptMode = JavascriptMode.disabled, + this.javascriptChannels, + this.navigationDelegate, + this.gestureRecognizers, + this.onPageStarted, + this.onPageFinished, + this.onProgress, + this.onWebResourceError, + this.debuggingEnabled = false, + this.gestureNavigationEnabled = false, + this.userAgent, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.allowsInlineMediaPlayback = false, + }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), + assert(allowsInlineMediaPlayback != null), + super(key: key); + + /// The WebView platform that's used by this WebView. + static final WebViewPlatform platform = CupertinoWebView(); + + /// If not null invoked once the web view is created. + final WebViewCreatedCallback? onWebViewCreated; + + /// Which gestures should be consumed by the web view. + /// + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + /// The initial URL to load. + final String? initialUrl; + + /// Whether Javascript execution is enabled. + final JavascriptMode javascriptMode; + + /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. + /// + /// For each [JavascriptChannel] in the set, a channel object is made available for the + /// JavaScript code in a window property named [JavascriptChannel.name]. + /// The JavaScript code can then call `postMessage` on that object to send a message that will be + /// passed to [JavascriptChannel.onMessageReceived]. + /// + /// For example for the following JavascriptChannel: + /// + /// ```dart + /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); + /// ``` + /// + /// JavaScript code can call: + /// + /// ```javascript + /// Print.postMessage('Hello'); + /// ``` + /// + /// To asynchronously invoke the message handler which will print the message to standard output. + /// + /// Adding a new JavaScript channel only takes affect after the next page is loaded. + /// + /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple + /// channels in the list. + /// + /// A null value is equivalent to an empty set. + final Set? javascriptChannels; + + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate? navigationDelegate; + + /// Controls whether inline playback of HTML5 videos is allowed on iOS. + /// + /// This field is ignored on Android because Android allows it by default. + /// + /// By default `allowsInlineMediaPlayback` is false. + final bool allowsInlineMediaPlayback; + + /// Invoked when a page starts loading. + final PageStartedCallback? onPageStarted; + + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any Javascript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.evaluateJavascript] can assume this. + final PageFinishedCallback? onPageFinished; + + /// Invoked when a page is loading. + final PageLoadingCallback? onProgress; + + /// Invoked when a web resource has failed to load. + /// + /// This callback is only called for the main page. + final WebResourceErrorCallback? onWebResourceError; + + /// Controls whether WebView debugging is enabled. + /// + /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). + /// + /// WebView debugging is enabled by default in dev builds on iOS. + /// + /// To debug WebViews on iOS: + /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) + /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> + /// + /// By default `debuggingEnabled` is false. + final bool debuggingEnabled; + + /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. + /// + /// This only works on iOS. + /// + /// By default `gestureNavigationEnabled` is false. + final bool gestureNavigationEnabled; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String? userAgent; + + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + + @override + _WebViewState createState() => _WebViewState(); +} + +class _WebViewState extends State { + final Completer _controller = + Completer(); + late final JavascriptChannelRegistry _javascriptChannelRegistry; + late final _PlatformCallbacksHandler _platformCallbacksHandler; + + @override + void initState() { + super.initState(); + _platformCallbacksHandler = _PlatformCallbacksHandler(widget); + _javascriptChannelRegistry = + JavascriptChannelRegistry(widget.javascriptChannels); + } + + @override + void didUpdateWidget(WebView oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.future.then((WebViewController controller) { + controller._updateWidget(widget); + }); + } + + @override + Widget build(BuildContext context) { + return WebView.platform.build( + context: context, + onWebViewPlatformCreated: + (WebViewPlatformController? webViewPlatformController) { + WebViewController controller = WebViewController._( + widget, + webViewPlatformController!, + _javascriptChannelRegistry, + ); + _controller.complete(controller); + + if (widget.onWebViewCreated != null) { + widget.onWebViewCreated!(controller); + } + }, + webViewPlatformCallbacksHandler: _platformCallbacksHandler, + creationParams: CreationParams( + initialUrl: widget.initialUrl, + webSettings: _webSettingsFromWidget(widget), + javascriptChannelNames: + _javascriptChannelRegistry.channels.keys.toSet(), + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, + userAgent: widget.userAgent, + ), + javascriptChannelRegistry: _javascriptChannelRegistry, + ); + } +} + +/// Controls a [WebView]. +/// +/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] +/// callback for a [WebView] widget. +class WebViewController { + WebViewController._( + this._widget, + this._webViewPlatformController, + this._javascriptChannelRegistry, + ) : assert(_webViewPlatformController != null) { + _settings = _webSettingsFromWidget(_widget); + } + + final JavascriptChannelRegistry _javascriptChannelRegistry; + + final WebViewPlatformController _webViewPlatformController; + + late WebSettings _settings; + + WebView _widget; + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, { + Map? headers, + }) async { + assert(url != null); + _validateUrlString(url); + return _webViewPlatformController.loadUrl(url, headers); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If [WebView.initialUrl] was never specified, returns `null`. + /// Note that this operation is asynchronous, and it is possible that the + /// current URL changes again by the time this function returns (in other + /// words, by the time this future completes, the WebView may be displaying a + /// different URL). + Future currentUrl() { + return _webViewPlatformController.currentUrl(); + } + + /// Checks whether there's a back history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has + /// changed by the time the future completed. + Future canGoBack() { + return _webViewPlatformController.canGoBack(); + } + + /// Checks whether there's a forward history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has + /// changed by the time the future completed. + Future canGoForward() { + return _webViewPlatformController.canGoForward(); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + return _webViewPlatformController.goBack(); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + return _webViewPlatformController.goForward(); + } + + /// Reloads the current URL. + Future reload() { + return _webViewPlatformController.reload(); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + /// + /// Note: Calling this method also triggers a reload. + Future clearCache() async { + await _webViewPlatformController.clearCache(); + return reload(); + } + + Future _updateWidget(WebView widget) async { + _widget = widget; + await _updateSettings(_webSettingsFromWidget(widget)); + await _updateJavascriptChannels( + _javascriptChannelRegistry.channels.values.toSet()); + } + + Future _updateSettings(WebSettings newSettings) { + final WebSettings update = + _clearUnchangedWebSettings(_settings, newSettings); + _settings = newSettings; + return _webViewPlatformController.updateSettings(update); + } + + Future _updateJavascriptChannels( + Set? newChannels) async { + final Set currentChannels = + _javascriptChannelRegistry.channels.keys.toSet(); + final Set newChannelNames = _extractChannelNames(newChannels); + final Set channelsToAdd = + newChannelNames.difference(currentChannels); + final Set channelsToRemove = + currentChannels.difference(newChannelNames); + if (channelsToRemove.isNotEmpty) { + await _webViewPlatformController + .removeJavascriptChannels(channelsToRemove); + } + if (channelsToAdd.isNotEmpty) { + await _webViewPlatformController.addJavascriptChannels(channelsToAdd); + } + _javascriptChannelRegistry.updateJavascriptChannelsFromSet(newChannels); + } + + /// Evaluates a JavaScript expression in the context of the current page. + /// + /// On Android returns the evaluation result as a JSON formatted string. + /// + /// On iOS depending on the value type the return value would be one of: + /// + /// - For primitive JavaScript types: the value string formatted (e.g JavaScript 100 returns '100'). + /// - For JavaScript arrays of supported types: a string formatted NSArray(e.g '(1,2,3), note that the string for NSArray is formatted and might contain newlines and extra spaces.'). + /// - Other non-primitive types are not supported on iOS and will complete the Future with an error. + /// + /// The Future completes with an error if a JavaScript error occurred, or on iOS, if the type of the + /// evaluated expression is not supported as described above. + /// + /// When evaluating Javascript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the Javascript + /// embedded in the main frame HTML has been loaded. + Future evaluateJavascript(String javascriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); + } + // TODO(amirh): remove this on when the invokeMethod update makes it to stable Flutter. + // https://github.com/flutter/flutter/issues/26431 + // ignore: strong_mode_implicit_dynamic_method + return _webViewPlatformController.evaluateJavascript(javascriptString); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + return _webViewPlatformController.getTitle(); + } + + /// Sets the WebView's content scroll position. + /// + /// The parameters `x` and `y` specify the scroll position in WebView pixels. + Future scrollTo(int x, int y) { + return _webViewPlatformController.scrollTo(x, y); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. + Future scrollBy(int x, int y) { + return _webViewPlatformController.scrollBy(x, y); + } + + /// Return the horizontal scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + return _webViewPlatformController.getScrollX(); + } + + /// Return the vertical scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + return _webViewPlatformController.getScrollY(); + } + + // This method assumes that no fields in `currentValue` are null. + WebSettings _clearUnchangedWebSettings( + WebSettings currentValue, WebSettings newValue) { + assert(currentValue.javascriptMode != null); + assert(currentValue.hasNavigationDelegate != null); + assert(currentValue.hasProgressTracking != null); + assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent != null); + assert(newValue.javascriptMode != null); + assert(newValue.hasNavigationDelegate != null); + assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent != null); + + JavascriptMode? javascriptMode; + bool? hasNavigationDelegate; + bool? hasProgressTracking; + bool? debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); + if (currentValue.javascriptMode != newValue.javascriptMode) { + javascriptMode = newValue.javascriptMode; + } + if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { + hasNavigationDelegate = newValue.hasNavigationDelegate; + } + if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { + hasProgressTracking = newValue.hasProgressTracking; + } + if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { + debuggingEnabled = newValue.debuggingEnabled; + } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } + + return WebSettings( + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + hasProgressTracking: hasProgressTracking, + debuggingEnabled: debuggingEnabled, + userAgent: userAgent, + ); + } + + Set _extractChannelNames(Set? channels) { + final Set channelNames = channels == null + ? {} + : channels.map((JavascriptChannel channel) => channel.name).toSet(); + return channelNames; + } + +// Throws an ArgumentError if `url` is not a valid URL string. + void _validateUrlString(String url) { + try { + final Uri uri = Uri.parse(url); + if (uri.scheme.isEmpty) { + throw ArgumentError('Missing scheme in URL string: "$url"'); + } + } on FormatException catch (e) { + throw ArgumentError(e); + } + } +} + +WebSettings _webSettingsFromWidget(WebView widget) { + return WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + hasProgressTracking: widget.onProgress != null, + debuggingEnabled: widget.debuggingEnabled, + gestureNavigationEnabled: widget.gestureNavigationEnabled, + allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, + userAgent: WebSetting.of(widget.userAgent), + ); +} + +class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { + _PlatformCallbacksHandler(this._webView); + + final WebView _webView; + + @override + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) async { + if (url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $url'); + return false; + } + print('allowing navigation to $url'); + return true; + } + + @override + void onPageStarted(String url) { + if (_webView.onPageStarted != null) { + _webView.onPageStarted!(url); + } + } + + @override + void onPageFinished(String url) { + if (_webView.onPageFinished != null) { + _webView.onPageFinished!(url); + } + } + + @override + void onProgress(int progress) { + if (_webView.onProgress != null) { + _webView.onProgress!(progress); + } + } + + void onWebResourceError(WebResourceError error) { + if (_webView.onWebResourceError != null) { + _webView.onWebResourceError!(error); + } + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml b/packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml new file mode 100644 index 000000000000..229da5e337a5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: webview_flutter_wkwebview_example +description: Demonstrates how to use the webview_flutter_wkwebview plugin. +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + webview_flutter_wkwebview: + # When depending on this package from a real application you should use: + # webview_flutter: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + espresso: ^0.1.0+2 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter + pedantic: ^1.10.0 + +flutter: + uses-material-design: true + assets: + - assets/sample_audio.ogg + - assets/sample_video.mp4 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 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 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Assets/.gitkeep b/packages/webview_flutter/webview_flutter_wkwebview/ios/Assets/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h new file mode 100644 index 000000000000..8fe331875250 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h @@ -0,0 +1,14 @@ +// Copyright 2013 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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTCookieManager : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m new file mode 100644 index 000000000000..f4783ffb4123 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m @@ -0,0 +1,49 @@ +// Copyright 2013 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 "FLTCookieManager.h" + +@implementation FLTCookieManager { +} + ++ (void)registerWithRegistrar:(NSObject *)registrar { + FLTCookieManager *instance = [[FLTCookieManager alloc] init]; + + FlutterMethodChannel *channel = + [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/cookie_manager" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([[call method] isEqualToString:@"clearCookies"]) { + [self clearCookies:result]; + } else { + result(FlutterMethodNotImplemented); + } +} + +- (void)clearCookies:(FlutterResult)result { + if (@available(iOS 9.0, *)) { + NSSet *websiteDataTypes = [NSSet setWithObject:WKWebsiteDataTypeCookies]; + WKWebsiteDataStore *dataStore = [WKWebsiteDataStore defaultDataStore]; + + void (^deleteAndNotify)(NSArray *) = + ^(NSArray *cookies) { + BOOL hasCookies = cookies.count > 0; + [dataStore removeDataOfTypes:websiteDataTypes + forDataRecords:cookies + completionHandler:^{ + result(@(hasCookies)); + }]; + }; + + [dataStore fetchDataRecordsOfTypes:websiteDataTypes completionHandler:deleteAndNotify]; + } else { + // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. + NSLog(@"Clearing cookies is not supported for Flutter WebViews prior to iOS 9."); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h new file mode 100644 index 000000000000..31edadc8cc05 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h @@ -0,0 +1,21 @@ +// Copyright 2013 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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTWKNavigationDelegate : NSObject + +- (instancetype)initWithChannel:(FlutterMethodChannel*)channel; + +/** + * Whether to delegate navigation decisions over the method channel. + */ +@property(nonatomic, assign) BOOL hasDartNavigationDelegate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m new file mode 100644 index 000000000000..8b7ee7d0cfb7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m @@ -0,0 +1,116 @@ +// Copyright 2013 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 "FLTWKNavigationDelegate.h" + +@implementation FLTWKNavigationDelegate { + FlutterMethodChannel *_methodChannel; +} + +- (instancetype)initWithChannel:(FlutterMethodChannel *)channel { + self = [super init]; + if (self) { + _methodChannel = channel; + } + return self; +} + +#pragma mark - WKNavigationDelegate conformance + +- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { + [_methodChannel invokeMethod:@"onPageStarted" arguments:@{@"url" : webView.URL.absoluteString}]; +} + +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction + decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { + if (!self.hasDartNavigationDelegate) { + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + NSDictionary *arguments = @{ + @"url" : navigationAction.request.URL.absoluteString, + @"isForMainFrame" : @(navigationAction.targetFrame.isMainFrame) + }; + [_methodChannel invokeMethod:@"navigationRequest" + arguments:arguments + result:^(id _Nullable result) { + if ([result isKindOfClass:[FlutterError class]]) { + NSLog(@"navigationRequest has unexpectedly completed with an error, " + @"allowing navigation."); + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + if (result == FlutterMethodNotImplemented) { + NSLog(@"navigationRequest was unexepectedly not implemented: %@, " + @"allowing navigation.", + result); + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + if (![result isKindOfClass:[NSNumber class]]) { + NSLog(@"navigationRequest unexpectedly returned a non boolean value: " + @"%@, allowing navigation.", + result); + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + NSNumber *typedResult = result; + decisionHandler([typedResult boolValue] ? WKNavigationActionPolicyAllow + : WKNavigationActionPolicyCancel); + }]; +} + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { + [_methodChannel invokeMethod:@"onPageFinished" arguments:@{@"url" : webView.URL.absoluteString}]; +} + ++ (id)errorCodeToString:(NSUInteger)code { + switch (code) { + case WKErrorUnknown: + return @"unknown"; + case WKErrorWebContentProcessTerminated: + return @"webContentProcessTerminated"; + case WKErrorWebViewInvalidated: + return @"webViewInvalidated"; + case WKErrorJavaScriptExceptionOccurred: + return @"javaScriptExceptionOccurred"; + case WKErrorJavaScriptResultTypeIsUnsupported: + return @"javaScriptResultTypeIsUnsupported"; + } + + return [NSNull null]; +} + +- (void)onWebResourceError:(NSError *)error { + [_methodChannel invokeMethod:@"onWebResourceError" + arguments:@{ + @"errorCode" : @(error.code), + @"domain" : error.domain, + @"description" : error.description, + @"errorType" : [FLTWKNavigationDelegate errorCodeToString:error.code], + }]; +} + +- (void)webView:(WKWebView *)webView + didFailNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + [self onWebResourceError:error]; +} + +- (void)webView:(WKWebView *)webView + didFailProvisionalNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + [self onWebResourceError:error]; +} + +- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView { + NSError *contentProcessTerminatedError = + [[NSError alloc] initWithDomain:WKErrorDomain + code:WKErrorWebContentProcessTerminated + userInfo:nil]; + [self onWebResourceError:contentProcessTerminatedError]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h new file mode 100644 index 000000000000..96af4ef6c578 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h @@ -0,0 +1,19 @@ +// Copyright 2013 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 +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTWKProgressionDelegate : NSObject + +- (instancetype)initWithWebView:(WKWebView *)webView channel:(FlutterMethodChannel *)channel; + +- (void)stopObservingProgress:(WKWebView *)webView; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m new file mode 100644 index 000000000000..8e7af4649aa0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m @@ -0,0 +1,41 @@ +// Copyright 2013 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 "FLTWKProgressionDelegate.h" + +NSString *const FLTWKEstimatedProgressKeyPath = @"estimatedProgress"; + +@implementation FLTWKProgressionDelegate { + FlutterMethodChannel *_methodChannel; +} + +- (instancetype)initWithWebView:(WKWebView *)webView channel:(FlutterMethodChannel *)channel { + self = [super init]; + if (self) { + _methodChannel = channel; + [webView addObserver:self + forKeyPath:FLTWKEstimatedProgressKeyPath + options:NSKeyValueObservingOptionNew + context:nil]; + } + return self; +} + +- (void)stopObservingProgress:(WKWebView *)webView { + [webView removeObserver:self forKeyPath:FLTWKEstimatedProgressKeyPath]; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + if ([keyPath isEqualToString:FLTWKEstimatedProgressKeyPath]) { + NSNumber *newValue = + change[NSKeyValueChangeNewKey] ?: 0; // newValue is anywhere between 0.0 and 1.0 + int newValueAsInt = [newValue floatValue] * 100; // Anywhere between 0 and 100 + [_methodChannel invokeMethod:@"onProgress" arguments:@{@"progress" : @(newValueAsInt)}]; + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h new file mode 100644 index 000000000000..2a80c7d886f2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h @@ -0,0 +1,8 @@ +// Copyright 2013 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 + +@interface FLTWebViewFlutterPlugin : NSObject +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m new file mode 100644 index 000000000000..9f01416acc6a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m @@ -0,0 +1,18 @@ +// Copyright 2013 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 "FLTWebViewFlutterPlugin.h" +#import "FLTCookieManager.h" +#import "FlutterWebView.h" + +@implementation FLTWebViewFlutterPlugin + ++ (void)registerWithRegistrar:(NSObject*)registrar { + FLTWebViewFactory* webviewFactory = + [[FLTWebViewFactory alloc] initWithMessenger:registrar.messenger]; + [registrar registerViewFactory:webviewFactory withId:@"plugins.flutter.io/webview"]; + [FLTCookieManager registerWithRegistrar:registrar]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h new file mode 100644 index 000000000000..6e795f7d1528 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h @@ -0,0 +1,32 @@ +// Copyright 2013 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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTWebViewController : NSObject + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + binaryMessenger:(NSObject*)messenger; + +- (UIView*)view; +@end + +@interface FLTWebViewFactory : NSObject +- (instancetype)initWithMessenger:(NSObject*)messenger; +@end + +/** + * The WkWebView used for the plugin. + * + * This class overrides some methods in `WKWebView` to serve the needs for the plugin. + */ +@interface FLTWKWebView : WKWebView +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m new file mode 100644 index 000000000000..c6d926d3cfc2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m @@ -0,0 +1,491 @@ +// Copyright 2013 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 "FlutterWebView.h" +#import "FLTWKNavigationDelegate.h" +#import "FLTWKProgressionDelegate.h" +#import "JavaScriptChannelHandler.h" + +@implementation FLTWebViewFactory { + NSObject* _messenger; +} + +- (instancetype)initWithMessenger:(NSObject*)messenger { + self = [super init]; + if (self) { + _messenger = messenger; + } + return self; +} + +- (NSObject*)createArgsCodec { + return [FlutterStandardMessageCodec sharedInstance]; +} + +- (NSObject*)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + FLTWebViewController* webviewController = [[FLTWebViewController alloc] initWithFrame:frame + viewIdentifier:viewId + arguments:args + binaryMessenger:_messenger]; + return webviewController; +} + +@end + +@implementation FLTWKWebView + +- (void)setFrame:(CGRect)frame { + [super setFrame:frame]; + self.scrollView.contentInset = UIEdgeInsetsZero; + // We don't want the contentInsets to be adjusted by iOS, flutter should always take control of + // webview's contentInsets. + // self.scrollView.contentInset = UIEdgeInsetsZero; + if (@available(iOS 11, *)) { + // Above iOS 11, adjust contentInset to compensate the adjustedContentInset so the sum will + // always be 0. + if (UIEdgeInsetsEqualToEdgeInsets(self.scrollView.adjustedContentInset, UIEdgeInsetsZero)) { + return; + } + UIEdgeInsets insetToAdjust = self.scrollView.adjustedContentInset; + self.scrollView.contentInset = UIEdgeInsetsMake(-insetToAdjust.top, -insetToAdjust.left, + -insetToAdjust.bottom, -insetToAdjust.right); + } +} + +@end + +@implementation FLTWebViewController { + FLTWKWebView* _webView; + int64_t _viewId; + FlutterMethodChannel* _channel; + NSString* _currentUrl; + // The set of registered JavaScript channel names. + NSMutableSet* _javaScriptChannelNames; + FLTWKNavigationDelegate* _navigationDelegate; + FLTWKProgressionDelegate* _progressionDelegate; +} + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + binaryMessenger:(NSObject*)messenger { + if (self = [super init]) { + _viewId = viewId; + + NSString* channelName = [NSString stringWithFormat:@"plugins.flutter.io/webview_%lld", viewId]; + _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger]; + _javaScriptChannelNames = [[NSMutableSet alloc] init]; + + WKUserContentController* userContentController = [[WKUserContentController alloc] init]; + if ([args[@"javascriptChannelNames"] isKindOfClass:[NSArray class]]) { + NSArray* javaScriptChannelNames = args[@"javascriptChannelNames"]; + [_javaScriptChannelNames addObjectsFromArray:javaScriptChannelNames]; + [self registerJavaScriptChannels:_javaScriptChannelNames controller:userContentController]; + } + + NSDictionary* settings = args[@"settings"]; + + WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init]; + [self applyConfigurationSettings:settings toConfiguration:configuration]; + configuration.userContentController = userContentController; + [self updateAutoMediaPlaybackPolicy:args[@"autoMediaPlaybackPolicy"] + inConfiguration:configuration]; + + _webView = [[FLTWKWebView alloc] initWithFrame:frame configuration:configuration]; + _navigationDelegate = [[FLTWKNavigationDelegate alloc] initWithChannel:_channel]; + _webView.UIDelegate = self; + _webView.navigationDelegate = _navigationDelegate; + __weak __typeof__(self) weakSelf = self; + [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + [weakSelf onMethodCall:call result:result]; + }]; + + if (@available(iOS 11.0, *)) { + _webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + if (@available(iOS 13.0, *)) { + _webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = NO; + } + } + + [self applySettings:settings]; + // TODO(amirh): return an error if apply settings failed once it's possible to do so. + // https://github.com/flutter/flutter/issues/36228 + + NSString* initialUrl = args[@"initialUrl"]; + if ([initialUrl isKindOfClass:[NSString class]]) { + [self loadUrl:initialUrl]; + } + } + return self; +} + +- (void)dealloc { + if (_progressionDelegate != nil) { + [_progressionDelegate stopObservingProgress:_webView]; + } +} + +- (UIView*)view { + return _webView; +} + +- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if ([[call method] isEqualToString:@"updateSettings"]) { + [self onUpdateSettings:call result:result]; + } else if ([[call method] isEqualToString:@"loadUrl"]) { + [self onLoadUrl:call result:result]; + } else if ([[call method] isEqualToString:@"canGoBack"]) { + [self onCanGoBack:call result:result]; + } else if ([[call method] isEqualToString:@"canGoForward"]) { + [self onCanGoForward:call result:result]; + } else if ([[call method] isEqualToString:@"goBack"]) { + [self onGoBack:call result:result]; + } else if ([[call method] isEqualToString:@"goForward"]) { + [self onGoForward:call result:result]; + } else if ([[call method] isEqualToString:@"reload"]) { + [self onReload:call result:result]; + } else if ([[call method] isEqualToString:@"currentUrl"]) { + [self onCurrentUrl:call result:result]; + } else if ([[call method] isEqualToString:@"evaluateJavascript"]) { + [self onEvaluateJavaScript:call result:result]; + } else if ([[call method] isEqualToString:@"addJavascriptChannels"]) { + [self onAddJavaScriptChannels:call result:result]; + } else if ([[call method] isEqualToString:@"removeJavascriptChannels"]) { + [self onRemoveJavaScriptChannels:call result:result]; + } else if ([[call method] isEqualToString:@"clearCache"]) { + [self clearCache:result]; + } else if ([[call method] isEqualToString:@"getTitle"]) { + [self onGetTitle:result]; + } else if ([[call method] isEqualToString:@"scrollTo"]) { + [self onScrollTo:call result:result]; + } else if ([[call method] isEqualToString:@"scrollBy"]) { + [self onScrollBy:call result:result]; + } else if ([[call method] isEqualToString:@"getScrollX"]) { + [self getScrollX:call result:result]; + } else if ([[call method] isEqualToString:@"getScrollY"]) { + [self getScrollY:call result:result]; + } else { + result(FlutterMethodNotImplemented); + } +} + +- (void)onUpdateSettings:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* error = [self applySettings:[call arguments]]; + if (error == nil) { + result(nil); + return; + } + result([FlutterError errorWithCode:@"updateSettings_failed" message:error details:nil]); +} + +- (void)onLoadUrl:(FlutterMethodCall*)call result:(FlutterResult)result { + if (![self loadRequest:[call arguments]]) { + result([FlutterError + errorWithCode:@"loadUrl_failed" + message:@"Failed parsing the URL" + details:[NSString stringWithFormat:@"Request was: '%@'", [call arguments]]]); + } else { + result(nil); + } +} + +- (void)onCanGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { + BOOL canGoBack = [_webView canGoBack]; + result(@(canGoBack)); +} + +- (void)onCanGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { + BOOL canGoForward = [_webView canGoForward]; + result(@(canGoForward)); +} + +- (void)onGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { + [_webView goBack]; + result(nil); +} + +- (void)onGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { + [_webView goForward]; + result(nil); +} + +- (void)onReload:(FlutterMethodCall*)call result:(FlutterResult)result { + [_webView reload]; + result(nil); +} + +- (void)onCurrentUrl:(FlutterMethodCall*)call result:(FlutterResult)result { + _currentUrl = [[_webView URL] absoluteString]; + result(_currentUrl); +} + +- (void)onEvaluateJavaScript:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* jsString = [call arguments]; + if (!jsString) { + result([FlutterError errorWithCode:@"evaluateJavaScript_failed" + message:@"JavaScript String cannot be null" + details:nil]); + return; + } + [_webView evaluateJavaScript:jsString + completionHandler:^(_Nullable id evaluateResult, NSError* _Nullable error) { + if (error) { + result([FlutterError + errorWithCode:@"evaluateJavaScript_failed" + message:@"Failed evaluating JavaScript" + details:[NSString stringWithFormat:@"JavaScript string was: '%@'\n%@", + jsString, error]]); + } else { + result([NSString stringWithFormat:@"%@", evaluateResult]); + } + }]; +} + +- (void)onAddJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { + NSArray* channelNames = [call arguments]; + NSSet* channelNamesSet = [[NSSet alloc] initWithArray:channelNames]; + [_javaScriptChannelNames addObjectsFromArray:channelNames]; + [self registerJavaScriptChannels:channelNamesSet + controller:_webView.configuration.userContentController]; + result(nil); +} + +- (void)onRemoveJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { + // WkWebView does not support removing a single user script, so instead we remove all + // user scripts, all message handlers. And re-register channels that shouldn't be removed. + [_webView.configuration.userContentController removeAllUserScripts]; + for (NSString* channelName in _javaScriptChannelNames) { + [_webView.configuration.userContentController removeScriptMessageHandlerForName:channelName]; + } + + NSArray* channelNamesToRemove = [call arguments]; + for (NSString* channelName in channelNamesToRemove) { + [_javaScriptChannelNames removeObject:channelName]; + } + + [self registerJavaScriptChannels:_javaScriptChannelNames + controller:_webView.configuration.userContentController]; + result(nil); +} + +- (void)clearCache:(FlutterResult)result { + if (@available(iOS 9.0, *)) { + NSSet* cacheDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; + WKWebsiteDataStore* dataStore = [WKWebsiteDataStore defaultDataStore]; + NSDate* dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; + [dataStore removeDataOfTypes:cacheDataTypes + modifiedSince:dateFrom + completionHandler:^{ + result(nil); + }]; + } else { + // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. + NSLog(@"Clearing cache is not supported for Flutter WebViews prior to iOS 9."); + } +} + +- (void)onGetTitle:(FlutterResult)result { + NSString* title = _webView.title; + result(title); +} + +- (void)onScrollTo:(FlutterMethodCall*)call result:(FlutterResult)result { + NSDictionary* arguments = [call arguments]; + int x = [arguments[@"x"] intValue]; + int y = [arguments[@"y"] intValue]; + + _webView.scrollView.contentOffset = CGPointMake(x, y); + result(nil); +} + +- (void)onScrollBy:(FlutterMethodCall*)call result:(FlutterResult)result { + CGPoint contentOffset = _webView.scrollView.contentOffset; + + NSDictionary* arguments = [call arguments]; + int x = [arguments[@"x"] intValue] + contentOffset.x; + int y = [arguments[@"y"] intValue] + contentOffset.y; + + _webView.scrollView.contentOffset = CGPointMake(x, y); + result(nil); +} + +- (void)getScrollX:(FlutterMethodCall*)call result:(FlutterResult)result { + int offsetX = _webView.scrollView.contentOffset.x; + result(@(offsetX)); +} + +- (void)getScrollY:(FlutterMethodCall*)call result:(FlutterResult)result { + int offsetY = _webView.scrollView.contentOffset.y; + result(@(offsetY)); +} + +// Returns nil when successful, or an error message when one or more keys are unknown. +- (NSString*)applySettings:(NSDictionary*)settings { + NSMutableArray* unknownKeys = [[NSMutableArray alloc] init]; + for (NSString* key in settings) { + if ([key isEqualToString:@"jsMode"]) { + NSNumber* mode = settings[key]; + [self updateJsMode:mode]; + } else if ([key isEqualToString:@"hasNavigationDelegate"]) { + NSNumber* hasDartNavigationDelegate = settings[key]; + _navigationDelegate.hasDartNavigationDelegate = [hasDartNavigationDelegate boolValue]; + } else if ([key isEqualToString:@"hasProgressTracking"]) { + NSNumber* hasProgressTrackingValue = settings[key]; + bool hasProgressTracking = [hasProgressTrackingValue boolValue]; + if (hasProgressTracking) { + _progressionDelegate = [[FLTWKProgressionDelegate alloc] initWithWebView:_webView + channel:_channel]; + } + } else if ([key isEqualToString:@"debuggingEnabled"]) { + // no-op debugging is always enabled on iOS. + } else if ([key isEqualToString:@"gestureNavigationEnabled"]) { + NSNumber* allowsBackForwardNavigationGestures = settings[key]; + _webView.allowsBackForwardNavigationGestures = + [allowsBackForwardNavigationGestures boolValue]; + } else if ([key isEqualToString:@"userAgent"]) { + NSString* userAgent = settings[key]; + [self updateUserAgent:[userAgent isEqual:[NSNull null]] ? nil : userAgent]; + } else { + [unknownKeys addObject:key]; + } + } + if ([unknownKeys count] == 0) { + return nil; + } + return [NSString stringWithFormat:@"webview_flutter: unknown setting keys: {%@}", + [unknownKeys componentsJoinedByString:@", "]]; +} + +- (void)applyConfigurationSettings:(NSDictionary*)settings + toConfiguration:(WKWebViewConfiguration*)configuration { + NSAssert(configuration != _webView.configuration, + @"configuration needs to be updated before webView.configuration."); + for (NSString* key in settings) { + if ([key isEqualToString:@"allowsInlineMediaPlayback"]) { + NSNumber* allowsInlineMediaPlayback = settings[key]; + configuration.allowsInlineMediaPlayback = [allowsInlineMediaPlayback boolValue]; + } + } +} + +- (void)updateJsMode:(NSNumber*)mode { + WKPreferences* preferences = [[_webView configuration] preferences]; + switch ([mode integerValue]) { + case 0: // disabled + [preferences setJavaScriptEnabled:NO]; + break; + case 1: // unrestricted + [preferences setJavaScriptEnabled:YES]; + break; + default: + NSLog(@"webview_flutter: unknown JavaScript mode: %@", mode); + } +} + +- (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy + inConfiguration:(WKWebViewConfiguration*)configuration { + switch ([policy integerValue]) { + case 0: // require_user_action_for_all_media_types + if (@available(iOS 10.0, *)) { + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; + } else if (@available(iOS 9.0, *)) { + configuration.requiresUserActionForMediaPlayback = true; + } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + configuration.mediaPlaybackRequiresUserAction = true; +#pragma clang diagnostic pop + } + break; + case 1: // always_allow + if (@available(iOS 10.0, *)) { + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; + } else if (@available(iOS 9.0, *)) { + configuration.requiresUserActionForMediaPlayback = false; + } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + configuration.mediaPlaybackRequiresUserAction = false; +#pragma clang diagnostic pop + } + break; + default: + NSLog(@"webview_flutter: unknown auto media playback policy: %@", policy); + } +} + +- (bool)loadRequest:(NSDictionary*)request { + if (!request) { + return false; + } + + NSString* url = request[@"url"]; + if ([url isKindOfClass:[NSString class]]) { + id headers = request[@"headers"]; + if ([headers isKindOfClass:[NSDictionary class]]) { + return [self loadUrl:url withHeaders:headers]; + } else { + return [self loadUrl:url]; + } + } + + return false; +} + +- (bool)loadUrl:(NSString*)url { + return [self loadUrl:url withHeaders:[NSMutableDictionary dictionary]]; +} + +- (bool)loadUrl:(NSString*)url withHeaders:(NSDictionary*)headers { + NSURL* nsUrl = [NSURL URLWithString:url]; + if (!nsUrl) { + return false; + } + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:nsUrl]; + [request setAllHTTPHeaderFields:headers]; + [_webView loadRequest:request]; + return true; +} + +- (void)registerJavaScriptChannels:(NSSet*)channelNames + controller:(WKUserContentController*)userContentController { + for (NSString* channelName in channelNames) { + FLTJavaScriptChannel* channel = + [[FLTJavaScriptChannel alloc] initWithMethodChannel:_channel + javaScriptChannelName:channelName]; + [userContentController addScriptMessageHandler:channel name:channelName]; + NSString* wrapperSource = [NSString + stringWithFormat:@"window.%@ = webkit.messageHandlers.%@;", channelName, channelName]; + WKUserScript* wrapperScript = + [[WKUserScript alloc] initWithSource:wrapperSource + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [userContentController addUserScript:wrapperScript]; + } +} + +- (void)updateUserAgent:(NSString*)userAgent { + if (@available(iOS 9.0, *)) { + [_webView setCustomUserAgent:userAgent]; + } else { + NSLog(@"Updating UserAgent is not supported for Flutter WebViews prior to iOS 9."); + } +} + +#pragma mark WKUIDelegate + +- (WKWebView*)webView:(WKWebView*)webView + createWebViewWithConfiguration:(WKWebViewConfiguration*)configuration + forNavigationAction:(WKNavigationAction*)navigationAction + windowFeatures:(WKWindowFeatures*)windowFeatures { + if (!navigationAction.targetFrame.isMainFrame) { + [webView loadRequest:navigationAction.request]; + } + + return nil; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h new file mode 100644 index 000000000000..a0a5ec657295 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h @@ -0,0 +1,17 @@ +// Copyright 2013 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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTJavaScriptChannel : NSObject + +- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel + javaScriptChannelName:(NSString*)javaScriptChannelName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m new file mode 100644 index 000000000000..ec9a363a4b2e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m @@ -0,0 +1,36 @@ +// Copyright 2013 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 "JavaScriptChannelHandler.h" + +@implementation FLTJavaScriptChannel { + FlutterMethodChannel* _methodChannel; + NSString* _javaScriptChannelName; +} + +- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel + javaScriptChannelName:(NSString*)javaScriptChannelName { + self = [super init]; + NSAssert(methodChannel != nil, @"methodChannel must not be null."); + NSAssert(javaScriptChannelName != nil, @"javaScriptChannelName must not be null."); + if (self) { + _methodChannel = methodChannel; + _javaScriptChannelName = javaScriptChannelName; + } + return self; +} + +- (void)userContentController:(WKUserContentController*)userContentController + didReceiveScriptMessage:(WKScriptMessage*)message { + NSAssert(_methodChannel != nil, @"Can't send a message to an unitialized JavaScript channel."); + NSAssert(_javaScriptChannelName != nil, + @"Can't send a message to an unitialized JavaScript channel."); + NSDictionary* arguments = @{ + @"channel" : _javaScriptChannelName, + @"message" : [NSString stringWithFormat:@"%@", message.body] + }; + [_methodChannel invokeMethod:@"javascriptChannelMessage" arguments:arguments]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec b/packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec new file mode 100644 index 000000000000..37905f147489 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'webview_flutter_wkwebview' + s.version = '0.0.1' + s.summary = 'A WebView Plugin for Flutter.' + s.description = <<-DESC +A Flutter plugin that provides a WebView widget. +Downloaded by pub (not CocoaPods). + DESC + s.homepage = 'https://github.com/flutter/plugins' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_wkwebview' } + s.documentation_url = 'https://pub.dev/packages/webview_flutter' + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + + s.platform = :ios, '8.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } +end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart new file mode 100644 index 000000000000..05b79d0a72e4 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart @@ -0,0 +1,49 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +/// Builds an iOS webview. +/// +/// This is used as the default implementation for [WebView.platform] on iOS. It uses +/// a [UiKitView] to embed the webview in the widget hierarchy, and uses a method channel to +/// communicate with the platform code. +class CupertinoWebView implements WebViewPlatform { + @override + Widget build({ + required BuildContext context, + required CreationParams creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }) { + return UiKitView( + viewType: 'plugins.flutter.io/webview', + onPlatformViewCreated: (int id) { + if (onWebViewPlatformCreated == null) { + return; + } + onWebViewPlatformCreated(MethodChannelWebViewPlatform( + id, + webViewPlatformCallbacksHandler, + javascriptChannelRegistry, + )); + }, + gestureRecognizers: gestureRecognizers, + creationParams: + MethodChannelWebViewPlatform.creationParamsToMap(creationParams), + creationParamsCodec: const StandardMessageCodec(), + ); + } + + @override + Future clearCookies() => MethodChannelWebViewPlatform.clearCookies(); +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart new file mode 100644 index 000000000000..bbec415dccd0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart @@ -0,0 +1,5 @@ +// Copyright 2013 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. + +export 'src/webview_cupertino.dart'; diff --git a/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml b/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml new file mode 100644 index 000000000000..c6f6d6f94f07 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml @@ -0,0 +1,29 @@ +name: webview_flutter_wkwebview +description: A Flutter plugin that provides a WebView widget based on Apple's WKWebView control. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_wkwebview +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 +version: 2.0.13 + +environment: + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" + +flutter: + plugin: + implements: webview_flutter + platforms: + ios: + pluginClass: FLTWebViewFlutterPlugin + +dependencies: + flutter: + sdk: flutter + + webview_flutter_platform_interface: ^1.0.0 + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + pedantic: ^1.10.0 \ No newline at end of file From 5ec9d0b37bfd5a656df005518cfd3d869b70f3ea Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 21 Sep 2021 15:03:58 -0400 Subject: [PATCH 309/364] [flutter_plugin_tools] Fix federated safety check (#4368) The new safety check doesn't allow simple platform-interface-only changes because it doesn't actually check that a non-interface package is actually modified before failing it for a modified platform interface. This fixes that, and adds a test case covering it. --- .../src/federation_safety_check_command.dart | 7 ++++ .../federation_safety_check_command_test.dart | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/script/tool/lib/src/federation_safety_check_command.dart b/script/tool/lib/src/federation_safety_check_command.dart index cb0da162e604..fd53d6cbaa67 100644 --- a/script/tool/lib/src/federation_safety_check_command.dart +++ b/script/tool/lib/src/federation_safety_check_command.dart @@ -135,6 +135,13 @@ class FederationSafetyCheckCommand extends PackageLoopingCommand { return PackageResult.success(); } + final List changedPackageFiles = + _changedDartFiles[package.directory.basename] ?? []; + if (changedPackageFiles.isEmpty) { + print('No Dart changes.'); + return PackageResult.success(); + } + // If the change would be flagged, but it appears to be a mass change // rather than a plugin-specific change, allow it with a warning. // diff --git a/script/tool/test/federation_safety_check_command_test.dart b/script/tool/test/federation_safety_check_command_test.dart index 4ae3ec5c76d0..e23485fbc8b7 100644 --- a/script/tool/test/federation_safety_check_command_test.dart +++ b/script/tool/test/federation_safety_check_command_test.dart @@ -125,6 +125,47 @@ void main() { ); }); + test('allows changes to just an interface package', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + createFakePlugin('foo', pluginGroupDir); + createFakePlugin('foo_ios', pluginGroupDir); + createFakePlugin('foo_android', pluginGroupDir); + + final String changedFileOutput = [ + platformInterface.childDirectory('lib').childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No Dart changes.'), + contains('Running for foo_android...'), + contains('No Dart changes.'), + contains('Running for foo_ios...'), + contains('No Dart changes.'), + contains('Running for foo_platform_interface...'), + contains('Ran for 3 package(s)'), + contains('Skipped 1 package(s)'), + ]), + ); + expect( + output, + isNot(contains([ + contains('No published changes for foo_platform_interface'), + ])), + ); + }); + test('allows changes to multiple non-interface packages', () async { final Directory pluginGroupDir = packagesDir.childDirectory('foo'); final Directory appFacing = createFakePlugin('foo', pluginGroupDir); From 42a17dc4de1ca8a87631095fb74fb669c7e522ed Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 21 Sep 2021 15:04:34 -0400 Subject: [PATCH 310/364] Require authors file (#4367) Adds a check to `publish-check` that there is an AUTHORS file present, since our license refers to "The Flutter Authors", so we want to have a file distributed with each package that says who the AUTHORS are (vs. just having a top-level repo AUTHORS file, which is not part of package distribution). Adds AUTHORS files to packages that have been created since the earlier one-time fix that added them, but didn't add a check to prevent future issues. Also updates the publish-check failure tests to include checks for specific output so that we know that they are failing for the reasons the test is expecting, bringing them up to current repo standards for failure tests. Fixes https://github.com/flutter/flutter/issues/89680 --- packages/camera/camera_web/AUTHORS | 66 ++++++++++++++ .../in_app_purchase_android/AUTHORS | 67 ++++++++++++++ .../in_app_purchase_ios/AUTHORS | 67 ++++++++++++++ .../AUTHORS | 67 ++++++++++++++ packages/quick_actions/quick_actions/AUTHORS | 67 ++++++++++++++ .../quick_actions_platform_interface/AUTHORS | 67 ++++++++++++++ script/tool/CHANGELOG.md | 1 + .../tool/lib/src/publish_check_command.dart | 21 ++++- script/tool/test/list_command_test.dart | 3 + .../tool/test/publish_check_command_test.dart | 89 +++++++++++++++++-- script/tool/test/util.dart | 7 ++ 11 files changed, 512 insertions(+), 10 deletions(-) create mode 100644 packages/camera/camera_web/AUTHORS create mode 100644 packages/in_app_purchase/in_app_purchase_android/AUTHORS create mode 100644 packages/in_app_purchase/in_app_purchase_ios/AUTHORS create mode 100644 packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS create mode 100644 packages/quick_actions/quick_actions/AUTHORS create mode 100644 packages/quick_actions/quick_actions_platform_interface/AUTHORS diff --git a/packages/camera/camera_web/AUTHORS b/packages/camera/camera_web/AUTHORS new file mode 100644 index 000000000000..493a0b4ef9c2 --- /dev/null +++ b/packages/camera/camera_web/AUTHORS @@ -0,0 +1,66 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> diff --git a/packages/in_app_purchase/in_app_purchase_android/AUTHORS b/packages/in_app_purchase/in_app_purchase_android/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/in_app_purchase/in_app_purchase_ios/AUTHORS b/packages/in_app_purchase/in_app_purchase_ios/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS b/packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/quick_actions/quick_actions/AUTHORS b/packages/quick_actions/quick_actions/AUTHORS new file mode 100644 index 000000000000..0ca697b6a756 --- /dev/null +++ b/packages/quick_actions/quick_actions/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Daniel Roek diff --git a/packages/quick_actions/quick_actions_platform_interface/AUTHORS b/packages/quick_actions/quick_actions_platform_interface/AUTHORS new file mode 100644 index 000000000000..0ca697b6a756 --- /dev/null +++ b/packages/quick_actions/quick_actions_platform_interface/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Daniel Roek diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 3be9173a505b..7e9cd3bec938 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -5,6 +5,7 @@ - Added a new `federation-safety-check` command to help catch changes to federated packages that have been done in such a way that they will pass in CI, but fail once the change is landed and published. +- `publish-check` now validates that there is an `AUTHORS` file. ## 0.7.1 diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index ab9f5f147495..563e0904552a 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -77,10 +77,17 @@ class PublishCheckCommand extends PackageLoopingCommand { @override Future runForPackage(RepositoryPackage package) async { - final _PublishCheckResult? result = await _passesPublishCheck(package); + _PublishCheckResult? result = await _passesPublishCheck(package); if (result == null) { return PackageResult.skip('Package is marked as unpublishable.'); } + if (!_passesAuthorsCheck(package)) { + _printImportantStatusMessage( + 'No AUTHORS file found. Packages must include an AUTHORS file.', + isError: true); + result = _PublishCheckResult.error; + } + if (result.index > _overallResult.index) { _overallResult = result; } @@ -189,7 +196,7 @@ class PublishCheckCommand extends PackageLoopingCommand { final String packageName = package.directory.basename; final Pubspec? pubspec = _tryParsePubspec(package); if (pubspec == null) { - print('no pubspec'); + print('No valid pubspec found.'); return _PublishCheckResult.error; } else if (pubspec.publishTo == 'none') { return null; @@ -239,6 +246,16 @@ HTTP response: ${pubVersionFinderResponse.httpResponse.body} } } + bool _passesAuthorsCheck(RepositoryPackage package) { + final List pathComponents = + package.directory.fileSystem.path.split(package.directory.path); + if (pathComponents.contains('third_party')) { + // Third-party packages aren't required to have an AUTHORS file. + return true; + } + return package.directory.childFile('AUTHORS').existsSync(); + } + void _printImportantStatusMessage(String message, {required bool isError}) { final String statusMessage = '${isError ? 'ERROR' : 'SUCCESS'}: $message'; if (getBoolArg(_machineFlag)) { diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart index 488fc9bcb1e4..fcdf9fafdb63 100644 --- a/script/tool/test/list_command_test.dart +++ b/script/tool/test/list_command_test.dart @@ -99,13 +99,16 @@ void main() { examples, unorderedEquals([ '/packages/plugin1/pubspec.yaml', + '/packages/plugin1/AUTHORS', '/packages/plugin1/CHANGELOG.md', '/packages/plugin1/example/pubspec.yaml', '/packages/plugin2/pubspec.yaml', + '/packages/plugin2/AUTHORS', '/packages/plugin2/CHANGELOG.md', '/packages/plugin2/example/example1/pubspec.yaml', '/packages/plugin2/example/example2/pubspec.yaml', '/packages/plugin3/pubspec.yaml', + '/packages/plugin3/AUTHORS', '/packages/plugin3/CHANGELOG.md', ]), ); diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index e1ab0e224e44..c5527af21736 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -69,12 +69,22 @@ void main() { createFakePlugin('plugin_tools_test_package_a', packagesDir); processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess(exitCode: 1) + MockProcess(exitCode: 1, stdout: 'Some error from pub') ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['publish-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); expect( - () => runCapturingPrint(runner, ['publish-check']), - throwsA(isA()), + output, + containsAllInOrder([ + contains('Some error from pub'), + contains('Unable to publish plugin_tools_test_package_a'), + ]), ); }); @@ -82,8 +92,58 @@ void main() { final Directory dir = createFakePlugin('c', packagesDir); await dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - expect(() => runCapturingPrint(runner, ['publish-check']), - throwsA(isA())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['publish-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No valid pubspec found.'), + ]), + ); + }); + + test('fails if AUTHORS is missing', () async { + final Directory package = createFakePackage('a_package', packagesDir); + package.childFile('AUTHORS').delete(); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['publish-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'No AUTHORS file found. Packages must include an AUTHORS file.'), + ]), + ); + }); + + test('does not require AUTHORS for third-party', () async { + final Directory package = createFakePackage( + 'a_package', + packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages')); + package.childFile('AUTHORS').delete(); + + final List output = + await runCapturingPrint(runner, ['publish-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for a_package'), + ]), + ); }); test('pass on prerelease if --allow-pre-release flag is on', () async { @@ -116,8 +176,21 @@ void main() { process, ]; - expect(runCapturingPrint(runner, ['publish-check']), - throwsA(isA())); + Error? commandError; + final List output = await runCapturingPrint( + runner, ['publish-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Packages with an SDK constraint on a pre-release of the Dart SDK'), + contains('Unable to publish d'), + ]), + ); }); test('Success message on stderr is not printed as an error', () async { @@ -324,7 +397,7 @@ void main() { // aren't controlled by this package, so asserting its exact format would // make the test fragile to irrelevant changes in those details. expect(output.first, contains(r''' - "no pubspec", + "No valid pubspec found.", "\n============================================================\n|| Running for no_publish_b\n============================================================\n", "url https://pub.dev/packages/no_publish_b.json", "no_publish_b.json", diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index e053100172c6..9abb34bef35a 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -126,6 +126,7 @@ Directory createFakePackage( ## $version * Some changes. '''); + createFakeAuthors(packageDirectory); if (examples.length == 1) { final Directory exampleDir = packageDirectory.childDirectory(examples.first) @@ -208,6 +209,12 @@ publish_to: $publishTo # Hardcoded safeguard to prevent this from somehow being parent.childFile('pubspec.yaml').writeAsStringSync(yaml); } +void createFakeAuthors(Directory parent) { + final File authorsFile = parent.childFile('AUTHORS'); + authorsFile.createSync(); + authorsFile.writeAsStringSync('Google Inc.'); +} + String _pluginPlatformSection( String platform, PlatformDetails support, String packageName) { String entry = ''; From ed2e7a78aee8e461be7d1b1aa1212770bf3d00b1 Mon Sep 17 00:00:00 2001 From: Rafael_Ferraz Date: Wed, 22 Sep 2021 02:33:05 -0300 Subject: [PATCH 311/364] [video_player] VTT Support (#2878) --- .../video_player/video_player/CHANGELOG.md | 4 + .../example/assets/bumble_bee_captions.vtt | 7 + .../video_player/example/lib/main.dart | 5 +- .../video_player/example/pubspec.yaml | 7 +- .../lib/src/closed_caption_file.dart | 4 + .../video_player/lib/src/sub_rip.dart | 20 +- .../video_player/lib/src/web_vtt.dart | 211 ++++++++++++++ .../video_player/video_player/pubspec.yaml | 3 +- .../video_player/test/web_vtt_test.dart | 261 ++++++++++++++++++ 9 files changed, 507 insertions(+), 15 deletions(-) create mode 100644 packages/video_player/video_player/example/assets/bumble_bee_captions.vtt create mode 100644 packages/video_player/video_player/lib/src/web_vtt.dart create mode 100644 packages/video_player/video_player/test/web_vtt_test.dart diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index de60af49b95d..539a5520e5be 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.5 + +* Support to closed caption WebVTT format added. + ## 2.2.4 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt b/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt new file mode 100644 index 000000000000..1dca2c58695e --- /dev/null +++ b/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00:00.200 --> 00:00:01.750 +[ Birds chirping ] + +00:00:02.300 --> 00:00:05.000 +[ Buzzing ] diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart index eef23197ef50..f035720396dd 100644 --- a/packages/video_player/video_player/example/lib/main.dart +++ b/packages/video_player/video_player/example/lib/main.dart @@ -210,8 +210,9 @@ class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> { Future _loadCaptions() async { final String fileContents = await DefaultAssetBundle.of(context) - .loadString('assets/bumble_bee_captions.srt'); - return SubRipCaptionFile(fileContents); + .loadString('assets/bumble_bee_captions.vtt'); + return WebVTTCaptionFile( + fileContents); // For vtt files, use WebVTTCaptionFile } @override diff --git a/packages/video_player/video_player/example/pubspec.yaml b/packages/video_player/video_player/example/pubspec.yaml index 63f179a06211..0539f3c6f56c 100644 --- a/packages/video_player/video_player/example/pubspec.yaml +++ b/packages/video_player/video_player/example/pubspec.yaml @@ -30,6 +30,7 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/flutter-mark-square-64.png - - assets/Butterfly-209.mp4 - - assets/bumble_bee_captions.srt + - assets/flutter-mark-square-64.png + - assets/Butterfly-209.mp4 + - assets/bumble_bee_captions.srt + - assets/bumble_bee_captions.vtt diff --git a/packages/video_player/video_player/lib/src/closed_caption_file.dart b/packages/video_player/video_player/lib/src/closed_caption_file.dart index 3c7d69b89598..e410e2652ad3 100644 --- a/packages/video_player/video_player/lib/src/closed_caption_file.dart +++ b/packages/video_player/video_player/lib/src/closed_caption_file.dart @@ -5,6 +5,9 @@ import 'sub_rip.dart'; export 'sub_rip.dart' show SubRipCaptionFile; +import 'web_vtt.dart'; +export 'web_vtt.dart' show WebVTTCaptionFile; + /// A structured representation of a parsed closed caption file. /// /// A closed caption file includes a list of captions, each with a start and end @@ -15,6 +18,7 @@ export 'sub_rip.dart' show SubRipCaptionFile; /// /// See: /// * [SubRipCaptionFile]. +/// * [WebVTTCaptionFile]. abstract class ClosedCaptionFile { /// The full list of captions from a given file. /// diff --git a/packages/video_player/video_player/lib/src/sub_rip.dart b/packages/video_player/video_player/lib/src/sub_rip.dart index 73cd8266c2e9..5d6863f72bb8 100644 --- a/packages/video_player/video_player/lib/src/sub_rip.dart +++ b/packages/video_player/video_player/lib/src/sub_rip.dart @@ -16,6 +16,8 @@ class SubRipCaptionFile extends ClosedCaptionFile { : _captions = _parseCaptionsFromSubRipString(fileContents); /// The entire body of the SubRip file. + // TODO(cyanglaz): Remove this public member as it doesn't seem need to exist. + // https://github.com/flutter/flutter/issues/90471 final String fileContents; @override @@ -30,15 +32,15 @@ List _parseCaptionsFromSubRipString(String file) { if (captionLines.length < 3) break; final int captionNumber = int.parse(captionLines[0]); - final _StartAndEnd startAndEnd = - _StartAndEnd.fromSubRipString(captionLines[1]); + final _CaptionRange captionRange = + _CaptionRange.fromSubRipString(captionLines[1]); final String text = captionLines.sublist(2).join('\n'); final Caption newCaption = Caption( number: captionNumber, - start: startAndEnd.start, - end: startAndEnd.end, + start: captionRange.start, + end: captionRange.end, text: text, ); if (newCaption.start != newCaption.end) { @@ -49,21 +51,21 @@ List _parseCaptionsFromSubRipString(String file) { return captions; } -class _StartAndEnd { +class _CaptionRange { final Duration start; final Duration end; - _StartAndEnd(this.start, this.end); + _CaptionRange(this.start, this.end); // Assumes format from an SubRip file. // For example: // 00:01:54,724 --> 00:01:56,760 - static _StartAndEnd fromSubRipString(String line) { + static _CaptionRange fromSubRipString(String line) { final RegExp format = RegExp(_subRipTimeStamp + _subRipArrow + _subRipTimeStamp); if (!format.hasMatch(line)) { - return _StartAndEnd(Duration.zero, Duration.zero); + return _CaptionRange(Duration.zero, Duration.zero); } final List times = line.split(_subRipArrow); @@ -71,7 +73,7 @@ class _StartAndEnd { final Duration start = _parseSubRipTimestamp(times[0]); final Duration end = _parseSubRipTimestamp(times[1]); - return _StartAndEnd(start, end); + return _CaptionRange(start, end); } } diff --git a/packages/video_player/video_player/lib/src/web_vtt.dart b/packages/video_player/video_player/lib/src/web_vtt.dart new file mode 100644 index 000000000000..6c4527d34d67 --- /dev/null +++ b/packages/video_player/video_player/lib/src/web_vtt.dart @@ -0,0 +1,211 @@ +// Copyright 2013 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 'package:html/dom.dart'; + +import 'closed_caption_file.dart'; +import 'package:html/parser.dart' as html_parser; + +/// Represents a [ClosedCaptionFile], parsed from the WebVTT file format. +/// See: https://en.wikipedia.org/wiki/WebVTT +class WebVTTCaptionFile extends ClosedCaptionFile { + /// Parses a string into a [ClosedCaptionFile], assuming [fileContents] is in + /// the WebVTT file format. + /// * See: https://en.wikipedia.org/wiki/WebVTT + WebVTTCaptionFile(String fileContents) + : _captions = _parseCaptionsFromWebVTTString(fileContents); + + @override + List get captions => _captions; + + final List _captions; +} + +List _parseCaptionsFromWebVTTString(String file) { + final List captions = []; + + // Ignore metadata + Set metadata = {'HEADER', 'NOTE', 'REGION', 'WEBVTT'}; + + int captionNumber = 1; + for (List captionLines in _readWebVTTFile(file)) { + // CaptionLines represent a complete caption. + // E.g + // [ + // [00:00.000 --> 01:24.000 align:center] + // ['Introduction'] + // ] + // If caption has just header or time, but no text, `captionLines.length` will be 1. + if (captionLines.length < 2) continue; + + // If caption has header equal metadata, ignore. + String metadaType = captionLines[0].split(' ')[0]; + if (metadata.contains(metadaType)) continue; + + // Caption has header + bool hasHeader = captionLines.length > 2; + if (hasHeader) { + final int? tryParseCaptionNumber = int.tryParse(captionLines[0]); + if (tryParseCaptionNumber != null) { + captionNumber = tryParseCaptionNumber; + } + } + + final _CaptionRange? captionRange = _CaptionRange.fromWebVTTString( + hasHeader ? captionLines[1] : captionLines[0], + ); + + if (captionRange == null) { + continue; + } + + final String text = captionLines.sublist(hasHeader ? 2 : 1).join('\n'); + + // TODO(cyanglaz): Handle special syntax in VTT captions. + // https://github.com/flutter/flutter/issues/90007. + final String textWithoutFormat = _extractTextFromHtml(text); + + final Caption newCaption = Caption( + number: captionNumber, + start: captionRange.start, + end: captionRange.end, + text: textWithoutFormat, + ); + captions.add(newCaption); + captionNumber++; + } + + return captions; +} + +class _CaptionRange { + final Duration start; + final Duration end; + + _CaptionRange(this.start, this.end); + + // Assumes format from an VTT file. + // For example: + // 00:09.000 --> 00:11.000 + static _CaptionRange? fromWebVTTString(String line) { + final RegExp format = + RegExp(_webVTTTimeStamp + _webVTTArrow + _webVTTTimeStamp); + + if (!format.hasMatch(line)) { + return null; + } + + final List times = line.split(_webVTTArrow); + + final Duration? start = _parseWebVTTTimestamp(times[0]); + final Duration? end = _parseWebVTTTimestamp(times[1]); + + if (start == null || end == null) { + return null; + } + + return _CaptionRange(start, end); + } +} + +String _extractTextFromHtml(String htmlString) { + final Document document = html_parser.parse(htmlString); + final Element? body = document.body; + if (body == null) { + return ''; + } + final Element? bodyElement = html_parser.parse(body.text).documentElement; + return bodyElement?.text ?? ''; +} + +// Parses a time stamp in an VTT file into a Duration. +// +// Returns `null` if `timestampString` is in an invalid format. +// +// For example: +// +// _parseWebVTTTimestamp('00:01:08.430') +// returns +// Duration(hours: 0, minutes: 1, seconds: 8, milliseconds: 430) +Duration? _parseWebVTTTimestamp(String timestampString) { + if (!RegExp(_webVTTTimeStamp).hasMatch(timestampString)) { + return null; + } + + final List dotSections = timestampString.split('.'); + final List timeComponents = dotSections[0].split(':'); + + // Validating and parsing the `timestampString`, invalid format will result this method + // to return `null`. See https://www.w3.org/TR/webvtt1/#webvtt-timestamp for valid + // WebVTT timestamp format. + if (timeComponents.length > 3 || timeComponents.length < 2) { + return null; + } + int hours = 0; + if (timeComponents.length == 3) { + final String hourString = timeComponents.removeAt(0); + if (hourString.length < 2) { + return null; + } + hours = int.parse(hourString); + } + final int minutes = int.parse(timeComponents.removeAt(0)); + if (minutes < 0 || minutes > 59) { + return null; + } + final int seconds = int.parse(timeComponents.removeAt(0)); + if (seconds < 0 || seconds > 59) { + return null; + } + + List milisecondsStyles = dotSections[1].split(" "); + + // TODO(cyanglaz): Handle caption styles. + // https://github.com/flutter/flutter/issues/90009. + // ```dart + // if (milisecondsStyles.length > 1) { + // List styles = milisecondsStyles.sublist(1); + // } + // ``` + // For a better readable code style, style parsing should happen before + // calling this method. See: https://github.com/flutter/plugins/pull/2878/files#r713381134. + int milliseconds = int.parse(milisecondsStyles[0]); + + return Duration( + hours: hours, + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + ); +} + +// Reads on VTT file and splits it into Lists of strings where each list is one +// caption. +List> _readWebVTTFile(String file) { + final List lines = LineSplitter.split(file).toList(); + + final List> captionStrings = >[]; + List currentCaption = []; + int lineIndex = 0; + for (final String line in lines) { + final bool isLineBlank = line.trim().isEmpty; + if (!isLineBlank) { + currentCaption.add(line); + } + + if (isLineBlank || lineIndex == lines.length - 1) { + captionStrings.add(currentCaption); + currentCaption = []; + } + + lineIndex += 1; + } + + return captionStrings; +} + +const String _webVTTTimeStamp = r'(\d+):(\d{2})(:\d{2})?\.(\d{3})'; +const String _webVTTArrow = r' --> '; diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 926add50f43c..a6ee2d594656 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, and web. repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.2.4 +version: 2.2.5 environment: sdk: ">=2.14.0 <3.0.0" @@ -32,6 +32,7 @@ dependencies: # TODO(amirh): Revisit this (either update this part in the design or the pub tool). # https://github.com/flutter/flutter/issues/46264 video_player_web: ^2.0.0 + html: ^0.15.0 dev_dependencies: flutter_test: diff --git a/packages/video_player/video_player/test/web_vtt_test.dart b/packages/video_player/video_player/test/web_vtt_test.dart new file mode 100644 index 000000000000..59fce98c5b71 --- /dev/null +++ b/packages/video_player/video_player/test/web_vtt_test.dart @@ -0,0 +1,261 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/src/closed_caption_file.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + group('Parse VTT file', () { + WebVTTCaptionFile parsedFile; + + test('with Metadata', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_metadata); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].start, Duration(seconds: 1)); + expect( + parsedFile.captions[0].end, Duration(seconds: 2, milliseconds: 500)); + expect(parsedFile.captions[0].text, 'We are in New York City'); + }); + + test('with Multiline', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_multiline); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].start, + Duration(seconds: 2, milliseconds: 800)); + expect( + parsedFile.captions[0].end, Duration(seconds: 3, milliseconds: 283)); + expect(parsedFile.captions[0].text, + "— It will perforate your stomach.\n— You could die."); + }); + + test('with styles tags', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_styles); + expect(parsedFile.captions.length, 3); + + expect(parsedFile.captions[0].start, + Duration(seconds: 5, milliseconds: 200)); + expect( + parsedFile.captions[0].end, Duration(seconds: 6, milliseconds: 000)); + expect(parsedFile.captions[0].text, + "You know I'm so excited my glasses are falling off here."); + }); + + test('with subtitling features', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_subtitling_features); + expect(parsedFile.captions.length, 3); + + expect(parsedFile.captions[0].number, 1); + expect(parsedFile.captions.last.start, Duration(seconds: 4)); + expect(parsedFile.captions.last.end, Duration(seconds: 5)); + expect(parsedFile.captions.last.text, "Transcrit par Célestes™"); + }); + + test('with [hours]:[minutes]:[seconds].[milliseconds].', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_hours); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].number, 1); + expect(parsedFile.captions.last.start, Duration(seconds: 1)); + expect(parsedFile.captions.last.end, Duration(seconds: 2)); + expect(parsedFile.captions.last.text, "This is a test."); + }); + + test('with [minutes]:[seconds].[milliseconds].', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_without_hours); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].number, 1); + expect(parsedFile.captions.last.start, Duration(seconds: 3)); + expect(parsedFile.captions.last.end, Duration(seconds: 4)); + expect(parsedFile.captions.last.text, "This is a test."); + }); + + test('with invalid seconds format returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_invalid_seconds); + expect(parsedFile.captions, isEmpty); + }); + + test('with invalid minutes format returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_invalid_minutes); + expect(parsedFile.captions, isEmpty); + }); + + test('with invalid hours format returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_invalid_hours); + expect(parsedFile.captions, isEmpty); + }); + + test('with invalid component length returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_time_component_too_long); + expect(parsedFile.captions, isEmpty); + + parsedFile = WebVTTCaptionFile(_time_component_too_short); + expect(parsedFile.captions, isEmpty); + }); + }); + + test('Parses VTT file with malformed input.', () { + final ClosedCaptionFile parsedFile = WebVTTCaptionFile(_malformedVTT); + + expect(parsedFile.captions.length, 1); + + final Caption firstCaption = parsedFile.captions.single; + expect(firstCaption.number, 1); + expect(firstCaption.start, Duration(seconds: 13)); + expect(firstCaption.end, Duration(seconds: 16, milliseconds: 0)); + expect(firstCaption.text, 'Valid'); + }); +} + +/// See https://www.w3.org/TR/webvtt1/#introduction-comments +const String _valid_vtt_with_metadata = ''' +WEBVTT Kind: captions; Language: en + +REGION +id:bill +width:40% +lines:3 +regionanchor:100%,100% +viewportanchor:90%,90% +scroll:up + +NOTE +This file was written by Jill. I hope +you enjoy reading it. Some things to +bear in mind: +- I was lip-reading, so the cues may +not be 100% accurate +- I didn’t pay too close attention to +when the cues should start or end. + +1 +00:01.000 --> 00:02.500 +We are in New York City +'''; + +/// See https://www.w3.org/TR/webvtt1/#introduction-multiple-lines +const String _valid_vtt_with_multiline = ''' +WEBVTT + +2 +00:02.800 --> 00:03.283 +— It will perforate your stomach. +— You could die. + +'''; + +/// See https://www.w3.org/TR/webvtt1/#styling +const String _valid_vtt_with_styles = ''' +WEBVTT + +00:05.200 --> 00:06.000 align:start size:50% +You know I'm so excited my glasses are falling off here. + +00:00:06.050 --> 00:00:06.150 +I have a different time! + +00:06.200 --> 00:06.900 +This is yellow text on a blue background + +'''; + +//See https://www.w3.org/TR/webvtt1/#introduction-other-features +const String _valid_vtt_with_subtitling_features = ''' +WEBVTT + +test +00:00.000 --> 00:02.000 +This is a test. + +Slide 1 +00:00:00.000 --> 00:00:10.700 +Title Slide + +crédit de transcription +00:04.000 --> 00:05.000 +Transcrit par Célestes™ + +'''; + +/// With format [hours]:[minutes]:[seconds].[milliseconds] +const String _valid_vtt_with_hours = ''' +WEBVTT + +test +00:00:01.000 --> 00:00:02.000 +This is a test. + +'''; + +/// Invalid seconds format. +const String _invalid_seconds = ''' +WEBVTT + +60:00:000.000 --> 60:02:000.000 +This is a test. + +'''; + +/// Invalid minutes format. +const String _invalid_minutes = ''' +WEBVTT + +60:60:00.000 --> 60:70:00.000 +This is a test. + +'''; + +/// Invalid hours format. +const String _invalid_hours = ''' +WEBVTT + +5:00:00.000 --> 5:02:00.000 +This is a test. + +'''; + +/// Invalid seconds format. +const String _time_component_too_long = ''' +WEBVTT + +60:00:00:00.000 --> 60:02:00:00.000 +This is a test. + +'''; + +/// Invalid seconds format. +const String _time_component_too_short = ''' +WEBVTT + +60:00.000 --> 60:02.000 +This is a test. + +'''; + +/// With format [minutes]:[seconds].[milliseconds] +const String _valid_vtt_without_hours = ''' +WEBVTT + +00:03.000 --> 00:04.000 +This is a test. + +'''; + +const String _malformedVTT = ''' + +WEBVTT Kind: captions; Language: en + +00:09.000--> 00:11.430 +This one should be ignored because the arrow needs a space. + +00:13.000 --> 00:16.000 +Valid + +00:16.000 --> 00:8.000 +This one should be ignored because the time is missing a digit. + +'''; From 33ba6bc29dc5c4f83165869d4723a5e52b86275c Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Wed, 22 Sep 2021 18:48:47 +0200 Subject: [PATCH 312/364] [camera_web] Update usage documentation (#4371) --- packages/camera/camera_web/CHANGELOG.md | 4 ++++ packages/camera/camera_web/README.md | 5 +++-- packages/camera/camera_web/pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 8596b3595852..dd9225f48ff4 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.1+1 + +* Update usage documentation. + ## 0.2.1 * Add video recording functionality. diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md index 918e695496a4..04bf665c1039 100644 --- a/packages/camera/camera_web/README.md +++ b/packages/camera/camera_web/README.md @@ -8,8 +8,9 @@ The web implementation of [`camera`][camera]. ### Depend on the package -This package is not an [endorsed implementation](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin) -of the camera plugin yet, so you'll need to [add it explicitly](https://pub.dev/packages/camera_web/install). +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `camera` +normally. This package will be automatically included in your app when you do. ## Example diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index f001fe92365b..f37500ad6e22 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.2.1 +version: 0.2.1+1 environment: sdk: ">=2.12.0 <3.0.0" From a1829ba7f5b2914865dfca4077edfcb0fe2ecc3b Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 22 Sep 2021 13:58:07 -0400 Subject: [PATCH 313/364] Add false secret lists, and enforce ordering (#4372) --- .../google_maps_flutter_web/pubspec.yaml | 4 ++ .../google_sign_in/pubspec.yaml | 8 ++++ .../tool/lib/src/pubspec_check_command.dart | 2 + .../tool/test/pubspec_check_command_test.dart | 39 ++++++++++++++++++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index 8a23916b0e98..08eda25352c8 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -31,3 +31,7 @@ dev_dependencies: flutter_test: sdk: flutter pedantic: ^1.10.0 + +# The example deliberately includes limited-use secrets. +false_secrets: + - /example/web/index.html diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index 79009373c5d1..c9931fd276f4 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -36,3 +36,11 @@ dev_dependencies: integration_test: sdk: flutter pedantic: ^1.10.0 + +# The example deliberately includes limited-use secrets. +false_secrets: + - /example/android/app/google-services.json + - /example/ios/Runner/GoogleService-Info.plist + - /example/ios/RunnerTests/GoogleSignInTests.m + - /example/lib/main.dart + - /example/web/index.html diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 605a8aa83a30..fec0dcef9ac7 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -39,6 +39,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { 'flutter:', 'dependencies:', 'dev_dependencies:', + 'false_secrets:', ]; static const List _majorPackageSections = [ @@ -46,6 +47,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { 'dependencies:', 'dev_dependencies:', 'flutter:', + 'false_secrets:', ]; static const String _expectedIssueLinkFormat = diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index c5d36013c40b..948136993d18 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -113,6 +113,13 @@ dev_dependencies: '''; } + String falseSecretsSection() { + return ''' +false_secrets: + - /lib/main.dart +'''; + } + test('passes for a plugin following conventions', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); @@ -122,6 +129,7 @@ ${environmentSection()} ${flutterSection(isPlugin: true)} ${dependenciesSection()} ${devDependenciesSection()} +${falseSecretsSection()} '''); final List output = await runCapturingPrint(runner, [ @@ -147,6 +155,7 @@ ${environmentSection()} ${dependenciesSection()} ${devDependenciesSection()} ${flutterSection()} +${falseSecretsSection()} '''); final List output = await runCapturingPrint(runner, [ @@ -399,7 +408,7 @@ ${dependenciesSection()} ); }); - test('fails when devDependencies section is out of order', () async { + test('fails when dev_dependencies section is out of order', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' @@ -426,6 +435,34 @@ ${dependenciesSection()} ); }); + test('fails when false_secrets section is out of order', () async { + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin', isPlugin: true)} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${falseSecretsSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Major sections should follow standard repository ordering:'), + ]), + ); + }); + test('fails when an implemenation package is missing "implements"', () async { final Directory pluginDirectory = createFakePlugin( From 44772b771a04591820cc076105a63067e5d02813 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Wed, 22 Sep 2021 15:58:07 -0700 Subject: [PATCH 314/364] [google_sign_in] Bump minimum Flutter version and iOS deployment target (#4334) --- packages/google_sign_in/google_sign_in/CHANGELOG.md | 4 ++++ packages/google_sign_in/google_sign_in/README.md | 2 ++ packages/google_sign_in/google_sign_in/example/pubspec.yaml | 4 ++-- .../google_sign_in/ios/google_sign_in.podspec | 2 +- packages/google_sign_in/google_sign_in/pubspec.yaml | 6 +++--- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 1f0be2e237b2..6107560ce610 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.1.1 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + ## 5.1.0 * Add reAuthenticate option to signInSilently to allow re-authentication to be requested diff --git a/packages/google_sign_in/google_sign_in/README.md b/packages/google_sign_in/google_sign_in/README.md index 6ed21c0fedd2..f3787474eeef 100644 --- a/packages/google_sign_in/google_sign_in/README.md +++ b/packages/google_sign_in/google_sign_in/README.md @@ -27,6 +27,8 @@ Otherwise, you may encounter `APIException` errors. ### iOS integration +This plugin requires iOS 9.0 or higher. + 1. [First register your application](https://firebase.google.com/docs/ios/setup). 2. Make sure the file you download in step 1 is named `GoogleService-Info.plist`. diff --git a/packages/google_sign_in/google_sign_in/example/pubspec.yaml b/packages/google_sign_in/google_sign_in/example/pubspec.yaml index 0379b9065333..dfd942d3d438 100644 --- a/packages/google_sign_in/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/example/pubspec.yaml @@ -3,8 +3,8 @@ description: Example of Google Sign-In plugin. publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.12.13+hotfix.4" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" dependencies: flutter: diff --git a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec index a0b73276fafa..270ca274f3e4 100644 --- a/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec +++ b/packages/google_sign_in/google_sign_in/ios/google_sign_in.podspec @@ -19,7 +19,7 @@ Enables Google Sign-In in Flutter apps. s.dependency 'GoogleSignIn', '~> 5.0' s.static_framework = true - s.platform = :ios, '8.0' + s.platform = :ios, '9.0' # GoogleSignIn ~> 5.0 does not support arm64 simulators. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' } diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index c9931fd276f4..aa0a686776fb 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,11 +3,11 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.1.0 +version: 5.1.1 environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" flutter: plugin: From 090e406cc9766cb071395cecc575f107936844d6 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 23 Sep 2021 13:28:05 +0200 Subject: [PATCH 315/364] [webview_flutter] Migrate main package to fully federated architecture. (#4366) --- .../webview_flutter/android/build.gradle | 57 -- .../webview_flutter/android/settings.gradle | 1 - .../android/src/main/AndroidManifest.xml | 2 - .../webviewflutter/DisplayListenerProxy.java | 147 --- .../webviewflutter/FlutterCookieManager.java | 56 -- .../FlutterDownloadListener.java | 33 - .../webviewflutter/FlutterWebView.java | 498 ----------- .../webviewflutter/FlutterWebViewClient.java | 323 ------- .../webviewflutter/FlutterWebViewFactory.java | 33 - .../webviewflutter/InputAwareWebView.java | 233 ----- .../webviewflutter/JavaScriptChannel.java | 58 -- ...readedInputConnectionProxyAdapterView.java | 112 --- .../webviewflutter/WebViewBuilder.java | 155 ---- .../webviewflutter/WebViewFlutterPlugin.java | 73 -- .../FlutterDownloadListenerTest.java | 42 - .../FlutterWebViewClientTest.java | 60 -- .../webviewflutter/FlutterWebViewTest.java | 66 -- .../webviewflutter/WebViewBuilderTest.java | 104 --- .../plugins/webviewflutter/WebViewTest.java | 49 - .../webview_flutter_test.dart | 22 +- .../webview_flutter/ios/Assets/.gitkeep | 0 .../ios/Classes/FLTCookieManager.h | 14 - .../ios/Classes/FLTCookieManager.m | 44 - .../ios/Classes/FLTWKNavigationDelegate.h | 21 - .../ios/Classes/FLTWKNavigationDelegate.m | 116 --- .../ios/Classes/FLTWKProgressionDelegate.h | 19 - .../ios/Classes/FLTWKProgressionDelegate.m | 41 - .../ios/Classes/FLTWebViewFlutterPlugin.h | 8 - .../ios/Classes/FLTWebViewFlutterPlugin.m | 18 - .../ios/Classes/FlutterWebView.h | 32 - .../ios/Classes/FlutterWebView.m | 475 ---------- .../ios/Classes/JavaScriptChannelHandler.h | 17 - .../ios/Classes/JavaScriptChannelHandler.m | 36 - .../ios/webview_flutter.podspec | 23 - .../lib/platform_interface.dart | 564 +----------- .../webview_flutter/lib/src/webview.dart | 681 ++++++++++++++ .../lib/src/webview_android.dart | 60 -- .../lib/src/webview_cupertino.dart | 47 - .../lib/src/webview_method_channel.dart | 216 ----- .../webview_flutter/lib/webview_flutter.dart | 834 +----------------- .../webview_flutter/pubspec.yaml | 8 +- .../test/webview_flutter_test.dart | 3 +- 42 files changed, 725 insertions(+), 4676 deletions(-) delete mode 100644 packages/webview_flutter/webview_flutter/android/build.gradle delete mode 100644 packages/webview_flutter/webview_flutter/android/settings.gradle delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java delete mode 100644 packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java delete mode 100644 packages/webview_flutter/webview_flutter/ios/Assets/.gitkeep delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.h delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.h delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.h delete mode 100644 packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.m delete mode 100644 packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec create mode 100644 packages/webview_flutter/webview_flutter/lib/src/webview.dart delete mode 100644 packages/webview_flutter/webview_flutter/lib/src/webview_android.dart delete mode 100644 packages/webview_flutter/webview_flutter/lib/src/webview_cupertino.dart delete mode 100644 packages/webview_flutter/webview_flutter/lib/src/webview_method_channel.dart diff --git a/packages/webview_flutter/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle deleted file mode 100644 index 4a164317c60f..000000000000 --- a/packages/webview_flutter/webview_flutter/android/build.gradle +++ /dev/null @@ -1,57 +0,0 @@ -group 'io.flutter.plugins.webviewflutter' -version '1.0-SNAPSHOT' - -buildscript { - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' - } -} - -rootProject.allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: 'com.android.library' - -android { - compileSdkVersion 29 - - defaultConfig { - minSdkVersion 19 - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - lintOptions { - disable 'InvalidPackage' - disable 'GradleDependency' - } - - dependencies { - implementation 'androidx.annotation:annotation:1.0.0' - implementation 'androidx.webkit:webkit:1.0.0' - testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-inline:3.11.1' - testImplementation 'androidx.test:core:1.3.0' - } - - - testOptions { - unitTests.includeAndroidResources = true - unitTests.returnDefaultValues = true - unitTests.all { - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/webview_flutter/webview_flutter/android/settings.gradle b/packages/webview_flutter/webview_flutter/android/settings.gradle deleted file mode 100644 index 5be7a4b4c692..000000000000 --- a/packages/webview_flutter/webview_flutter/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'webview_flutter' diff --git a/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml deleted file mode 100644 index a087f2c75c24..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java deleted file mode 100644 index 31e3fe08c057..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static android.hardware.display.DisplayManager.DisplayListener; - -import android.annotation.TargetApi; -import android.hardware.display.DisplayManager; -import android.os.Build; -import android.util.Log; -import java.lang.reflect.Field; -import java.util.ArrayList; - -/** - * Works around an Android WebView bug by filtering some DisplayListener invocations. - * - *

    Older Android WebView versions had assumed that when {@link DisplayListener#onDisplayChanged} - * is invoked, the display ID it is provided is of a valid display. However it turns out that when a - * display is removed Android may call onDisplayChanged with the ID of the removed display, in this - * case the Android WebView code tries to fetch and use the display with this ID and crashes with an - * NPE. - * - *

    This issue was fixed in the Android WebView code in - * https://chromium-review.googlesource.com/517913 which is available starting WebView version - * 58.0.3029.125 however older webviews in the wild still have this issue. - * - *

    Since Flutter removes virtual displays whenever a platform view is resized the webview crash - * is more likely to happen than other apps. And users were reporting this issue see: - * https://github.com/flutter/flutter/issues/30420 - * - *

    This class works around the webview bug by unregistering the WebView's DisplayListener, and - * instead registering its own DisplayListener which delegates the callbacks to the WebView's - * listener unless it's a onDisplayChanged for an invalid display. - * - *

    I did not find a clean way to get a handle of the WebView's DisplayListener so I'm using - * reflection to fetch all registered listeners before and after initializing a webview. In the - * first initialization of a webview within the process the difference between the lists is the - * webview's display listener. - */ -@TargetApi(Build.VERSION_CODES.KITKAT) -class DisplayListenerProxy { - private static final String TAG = "DisplayListenerProxy"; - - private ArrayList listenersBeforeWebView; - - /** Should be called prior to the webview's initialization. */ - void onPreWebViewInitialization(DisplayManager displayManager) { - listenersBeforeWebView = yoinkDisplayListeners(displayManager); - } - - /** Should be called after the webview's initialization. */ - void onPostWebViewInitialization(final DisplayManager displayManager) { - final ArrayList webViewListeners = yoinkDisplayListeners(displayManager); - // We recorded the list of listeners prior to initializing webview, any new listeners we see - // after initializing the webview are listeners added by the webview. - webViewListeners.removeAll(listenersBeforeWebView); - - if (webViewListeners.isEmpty()) { - // The Android WebView registers a single display listener per process (even if there - // are multiple WebView instances) so this list is expected to be non-empty only the - // first time a webview is initialized. - // Note that in an add2app scenario if the application had instantiated a non Flutter - // WebView prior to instantiating the Flutter WebView we are not able to get a reference - // to the WebView's display listener and can't work around the bug. - // - // This means that webview resizes in add2app Flutter apps with a non Flutter WebView - // running on a system with a webview prior to 58.0.3029.125 may crash (the Android's - // behavior seems to be racy so it doesn't always happen). - return; - } - - for (DisplayListener webViewListener : webViewListeners) { - // Note that while DisplayManager.unregisterDisplayListener throws when given an - // unregistered listener, this isn't an issue as the WebView code never calls - // unregisterDisplayListener. - displayManager.unregisterDisplayListener(webViewListener); - - // We never explicitly unregister this listener as the webview's listener is never - // unregistered (it's released when the process is terminated). - displayManager.registerDisplayListener( - new DisplayListener() { - @Override - public void onDisplayAdded(int displayId) { - for (DisplayListener webViewListener : webViewListeners) { - webViewListener.onDisplayAdded(displayId); - } - } - - @Override - public void onDisplayRemoved(int displayId) { - for (DisplayListener webViewListener : webViewListeners) { - webViewListener.onDisplayRemoved(displayId); - } - } - - @Override - public void onDisplayChanged(int displayId) { - if (displayManager.getDisplay(displayId) == null) { - return; - } - for (DisplayListener webViewListener : webViewListeners) { - webViewListener.onDisplayChanged(displayId); - } - } - }, - null); - } - } - - @SuppressWarnings({"unchecked", "PrivateApi"}) - private static ArrayList yoinkDisplayListeners(DisplayManager displayManager) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // We cannot use reflection on Android P, but it shouldn't matter as it shipped - // with WebView 66.0.3359.158 and the WebView version the bug this code is working around was - // fixed in 61.0.3116.0. - return new ArrayList<>(); - } - try { - Field displayManagerGlobalField = DisplayManager.class.getDeclaredField("mGlobal"); - displayManagerGlobalField.setAccessible(true); - Object displayManagerGlobal = displayManagerGlobalField.get(displayManager); - Field displayListenersField = - displayManagerGlobal.getClass().getDeclaredField("mDisplayListeners"); - displayListenersField.setAccessible(true); - ArrayList delegates = - (ArrayList) displayListenersField.get(displayManagerGlobal); - - Field listenerField = null; - ArrayList listeners = new ArrayList<>(); - for (Object delegate : delegates) { - if (listenerField == null) { - listenerField = delegate.getClass().getField("mListener"); - listenerField.setAccessible(true); - } - DisplayManager.DisplayListener listener = - (DisplayManager.DisplayListener) listenerField.get(delegate); - listeners.add(listener); - } - return listeners; - } catch (NoSuchFieldException | IllegalAccessException e) { - Log.w(TAG, "Could not extract WebView's display listeners. " + e); - return new ArrayList<>(); - } - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java deleted file mode 100644 index df3f21daadeb..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterCookieManager.java +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.os.Build; -import android.os.Build.VERSION_CODES; -import android.webkit.CookieManager; -import android.webkit.ValueCallback; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; - -class FlutterCookieManager implements MethodCallHandler { - private final MethodChannel methodChannel; - - FlutterCookieManager(BinaryMessenger messenger) { - methodChannel = new MethodChannel(messenger, "plugins.flutter.io/cookie_manager"); - methodChannel.setMethodCallHandler(this); - } - - @Override - public void onMethodCall(MethodCall methodCall, Result result) { - switch (methodCall.method) { - case "clearCookies": - clearCookies(result); - break; - default: - result.notImplemented(); - } - } - - void dispose() { - methodChannel.setMethodCallHandler(null); - } - - private static void clearCookies(final Result result) { - CookieManager cookieManager = CookieManager.getInstance(); - final boolean hasCookies = cookieManager.hasCookies(); - if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { - cookieManager.removeAllCookies( - new ValueCallback() { - @Override - public void onReceiveValue(Boolean value) { - result.success(hasCookies); - } - }); - } else { - cookieManager.removeAllCookie(); - result.success(hasCookies); - } - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java deleted file mode 100644 index cfad4e315514..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterDownloadListener.java +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.webkit.DownloadListener; -import android.webkit.WebView; - -/** DownloadListener to notify the {@link FlutterWebViewClient} of download starts */ -public class FlutterDownloadListener implements DownloadListener { - private final FlutterWebViewClient webViewClient; - private WebView webView; - - public FlutterDownloadListener(FlutterWebViewClient webViewClient) { - this.webViewClient = webViewClient; - } - - /** Sets the {@link WebView} that the result of the navigation delegate will be send to. */ - public void setWebView(WebView webView) { - this.webView = webView; - } - - @Override - public void onDownloadStart( - String url, - String userAgent, - String contentDisposition, - String mimetype, - long contentLength) { - webViewClient.notifyDownload(webView, url); - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java deleted file mode 100644 index 4651a5f5ae22..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ /dev/null @@ -1,498 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.annotation.TargetApi; -import android.content.Context; -import android.hardware.display.DisplayManager; -import android.os.Build; -import android.os.Handler; -import android.os.Message; -import android.view.View; -import android.webkit.DownloadListener; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceRequest; -import android.webkit.WebStorage; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugin.platform.PlatformView; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -public class FlutterWebView implements PlatformView, MethodCallHandler { - - private static final String JS_CHANNEL_NAMES_FIELD = "javascriptChannelNames"; - private final WebView webView; - private final MethodChannel methodChannel; - private final FlutterWebViewClient flutterWebViewClient; - private final Handler platformThreadHandler; - - // Verifies that a url opened by `Window.open` has a secure url. - private class FlutterWebChromeClient extends WebChromeClient { - - @Override - public boolean onCreateWindow( - final WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) { - final WebViewClient webViewClient = - new WebViewClient() { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - @Override - public boolean shouldOverrideUrlLoading( - @NonNull WebView view, @NonNull WebResourceRequest request) { - final String url = request.getUrl().toString(); - if (!flutterWebViewClient.shouldOverrideUrlLoading( - FlutterWebView.this.webView, request)) { - webView.loadUrl(url); - } - return true; - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (!flutterWebViewClient.shouldOverrideUrlLoading( - FlutterWebView.this.webView, url)) { - webView.loadUrl(url); - } - return true; - } - }; - - final WebView newWebView = new WebView(view.getContext()); - newWebView.setWebViewClient(webViewClient); - - final WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj; - transport.setWebView(newWebView); - resultMsg.sendToTarget(); - - return true; - } - - @Override - public void onProgressChanged(WebView view, int progress) { - flutterWebViewClient.onLoadingProgress(progress); - } - } - - @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) - @SuppressWarnings("unchecked") - FlutterWebView( - final Context context, - MethodChannel methodChannel, - Map params, - View containerView) { - - DisplayListenerProxy displayListenerProxy = new DisplayListenerProxy(); - DisplayManager displayManager = - (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); - displayListenerProxy.onPreWebViewInitialization(displayManager); - - this.methodChannel = methodChannel; - this.methodChannel.setMethodCallHandler(this); - - flutterWebViewClient = new FlutterWebViewClient(methodChannel); - - FlutterDownloadListener flutterDownloadListener = - new FlutterDownloadListener(flutterWebViewClient); - webView = - createWebView( - new WebViewBuilder(context, containerView), - params, - new FlutterWebChromeClient(), - flutterDownloadListener); - flutterDownloadListener.setWebView(webView); - - displayListenerProxy.onPostWebViewInitialization(displayManager); - - platformThreadHandler = new Handler(context.getMainLooper()); - - Map settings = (Map) params.get("settings"); - if (settings != null) { - applySettings(settings); - } - - if (params.containsKey(JS_CHANNEL_NAMES_FIELD)) { - List names = (List) params.get(JS_CHANNEL_NAMES_FIELD); - if (names != null) { - registerJavaScriptChannelNames(names); - } - } - - Integer autoMediaPlaybackPolicy = (Integer) params.get("autoMediaPlaybackPolicy"); - if (autoMediaPlaybackPolicy != null) { - updateAutoMediaPlaybackPolicy(autoMediaPlaybackPolicy); - } - if (params.containsKey("userAgent")) { - String userAgent = (String) params.get("userAgent"); - updateUserAgent(userAgent); - } - if (params.containsKey("initialUrl")) { - String url = (String) params.get("initialUrl"); - webView.loadUrl(url); - } - } - - /** - * Creates a {@link android.webkit.WebView} and configures it according to the supplied - * parameters. - * - *

    The {@link WebView} is configured with the following predefined settings: - * - *

      - *
    • always enable the DOM storage API; - *
    • always allow JavaScript to automatically open windows; - *
    • always allow support for multiple windows; - *
    • always use the {@link FlutterWebChromeClient} as web Chrome client. - *
    - * - *

    Important: This method is visible for testing purposes only and should - * never be called from outside this class. - * - * @param webViewBuilder a {@link WebViewBuilder} which is responsible for building the {@link - * WebView}. - * @param params creation parameters received over the method channel. - * @param webChromeClient an implementation of WebChromeClient This value may be null. - * @return The new {@link android.webkit.WebView} object. - */ - @VisibleForTesting - static WebView createWebView( - WebViewBuilder webViewBuilder, - Map params, - WebChromeClient webChromeClient, - @Nullable DownloadListener downloadListener) { - boolean usesHybridComposition = Boolean.TRUE.equals(params.get("usesHybridComposition")); - webViewBuilder - .setUsesHybridComposition(usesHybridComposition) - .setDomStorageEnabled(true) // Always enable DOM storage API. - .setJavaScriptCanOpenWindowsAutomatically( - true) // Always allow automatically opening of windows. - .setSupportMultipleWindows(true) // Always support multiple windows. - .setWebChromeClient(webChromeClient) - .setDownloadListener( - downloadListener); // Always use {@link FlutterWebChromeClient} as web Chrome client. - - return webViewBuilder.build(); - } - - @Override - public View getView() { - return webView; - } - - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once flutter/engine#9727 rolls to stable. - public void onInputConnectionUnlocked() { - if (webView instanceof InputAwareWebView) { - ((InputAwareWebView) webView).unlockInputConnection(); - } - } - - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once flutter/engine#9727 rolls to stable. - public void onInputConnectionLocked() { - if (webView instanceof InputAwareWebView) { - ((InputAwareWebView) webView).lockInputConnection(); - } - } - - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once stable passes v1.10.9. - public void onFlutterViewAttached(View flutterView) { - if (webView instanceof InputAwareWebView) { - ((InputAwareWebView) webView).setContainerView(flutterView); - } - } - - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once stable passes v1.10.9. - public void onFlutterViewDetached() { - if (webView instanceof InputAwareWebView) { - ((InputAwareWebView) webView).setContainerView(null); - } - } - - @Override - public void onMethodCall(MethodCall methodCall, Result result) { - switch (methodCall.method) { - case "loadUrl": - loadUrl(methodCall, result); - break; - case "updateSettings": - updateSettings(methodCall, result); - break; - case "canGoBack": - canGoBack(result); - break; - case "canGoForward": - canGoForward(result); - break; - case "goBack": - goBack(result); - break; - case "goForward": - goForward(result); - break; - case "reload": - reload(result); - break; - case "currentUrl": - currentUrl(result); - break; - case "evaluateJavascript": - evaluateJavaScript(methodCall, result); - break; - case "addJavascriptChannels": - addJavaScriptChannels(methodCall, result); - break; - case "removeJavascriptChannels": - removeJavaScriptChannels(methodCall, result); - break; - case "clearCache": - clearCache(result); - break; - case "getTitle": - getTitle(result); - break; - case "scrollTo": - scrollTo(methodCall, result); - break; - case "scrollBy": - scrollBy(methodCall, result); - break; - case "getScrollX": - getScrollX(result); - break; - case "getScrollY": - getScrollY(result); - break; - default: - result.notImplemented(); - } - } - - @SuppressWarnings("unchecked") - private void loadUrl(MethodCall methodCall, Result result) { - Map request = (Map) methodCall.arguments; - String url = (String) request.get("url"); - Map headers = (Map) request.get("headers"); - if (headers == null) { - headers = Collections.emptyMap(); - } - webView.loadUrl(url, headers); - result.success(null); - } - - private void canGoBack(Result result) { - result.success(webView.canGoBack()); - } - - private void canGoForward(Result result) { - result.success(webView.canGoForward()); - } - - private void goBack(Result result) { - if (webView.canGoBack()) { - webView.goBack(); - } - result.success(null); - } - - private void goForward(Result result) { - if (webView.canGoForward()) { - webView.goForward(); - } - result.success(null); - } - - private void reload(Result result) { - webView.reload(); - result.success(null); - } - - private void currentUrl(Result result) { - result.success(webView.getUrl()); - } - - @SuppressWarnings("unchecked") - private void updateSettings(MethodCall methodCall, Result result) { - applySettings((Map) methodCall.arguments); - result.success(null); - } - - @TargetApi(Build.VERSION_CODES.KITKAT) - private void evaluateJavaScript(MethodCall methodCall, final Result result) { - String jsString = (String) methodCall.arguments; - if (jsString == null) { - throw new UnsupportedOperationException("JavaScript string cannot be null"); - } - webView.evaluateJavascript( - jsString, - new android.webkit.ValueCallback() { - @Override - public void onReceiveValue(String value) { - result.success(value); - } - }); - } - - @SuppressWarnings("unchecked") - private void addJavaScriptChannels(MethodCall methodCall, Result result) { - List channelNames = (List) methodCall.arguments; - registerJavaScriptChannelNames(channelNames); - result.success(null); - } - - @SuppressWarnings("unchecked") - private void removeJavaScriptChannels(MethodCall methodCall, Result result) { - List channelNames = (List) methodCall.arguments; - for (String channelName : channelNames) { - webView.removeJavascriptInterface(channelName); - } - result.success(null); - } - - private void clearCache(Result result) { - webView.clearCache(true); - WebStorage.getInstance().deleteAllData(); - result.success(null); - } - - private void getTitle(Result result) { - result.success(webView.getTitle()); - } - - private void scrollTo(MethodCall methodCall, Result result) { - Map request = methodCall.arguments(); - int x = (int) request.get("x"); - int y = (int) request.get("y"); - - webView.scrollTo(x, y); - - result.success(null); - } - - private void scrollBy(MethodCall methodCall, Result result) { - Map request = methodCall.arguments(); - int x = (int) request.get("x"); - int y = (int) request.get("y"); - - webView.scrollBy(x, y); - result.success(null); - } - - private void getScrollX(Result result) { - result.success(webView.getScrollX()); - } - - private void getScrollY(Result result) { - result.success(webView.getScrollY()); - } - - private void applySettings(Map settings) { - for (String key : settings.keySet()) { - switch (key) { - case "jsMode": - Integer mode = (Integer) settings.get(key); - if (mode != null) { - updateJsMode(mode); - } - break; - case "hasNavigationDelegate": - final boolean hasNavigationDelegate = (boolean) settings.get(key); - - final WebViewClient webViewClient = - flutterWebViewClient.createWebViewClient(hasNavigationDelegate); - - webView.setWebViewClient(webViewClient); - break; - case "debuggingEnabled": - final boolean debuggingEnabled = (boolean) settings.get(key); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - webView.setWebContentsDebuggingEnabled(debuggingEnabled); - } - break; - case "hasProgressTracking": - flutterWebViewClient.hasProgressTracking = (boolean) settings.get(key); - break; - case "gestureNavigationEnabled": - break; - case "userAgent": - updateUserAgent((String) settings.get(key)); - break; - case "allowsInlineMediaPlayback": - // no-op inline media playback is always allowed on Android. - break; - default: - throw new IllegalArgumentException("Unknown WebView setting: " + key); - } - } - } - - private void updateJsMode(int mode) { - switch (mode) { - case 0: // disabled - webView.getSettings().setJavaScriptEnabled(false); - break; - case 1: // unrestricted - webView.getSettings().setJavaScriptEnabled(true); - break; - default: - throw new IllegalArgumentException("Trying to set unknown JavaScript mode: " + mode); - } - } - - private void updateAutoMediaPlaybackPolicy(int mode) { - // This is the index of the AutoMediaPlaybackPolicy enum, index 1 is always_allow, for all - // other values we require a user gesture. - boolean requireUserGesture = mode != 1; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - webView.getSettings().setMediaPlaybackRequiresUserGesture(requireUserGesture); - } - } - - private void registerJavaScriptChannelNames(List channelNames) { - for (String channelName : channelNames) { - webView.addJavascriptInterface( - new JavaScriptChannel(methodChannel, channelName, platformThreadHandler), channelName); - } - } - - private void updateUserAgent(String userAgent) { - webView.getSettings().setUserAgentString(userAgent); - } - - @Override - public void dispose() { - methodChannel.setMethodCallHandler(null); - if (webView instanceof InputAwareWebView) { - ((InputAwareWebView) webView).dispose(); - } - webView.destroy(); - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java deleted file mode 100644 index 260ef8e8b15d..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.graphics.Bitmap; -import android.os.Build; -import android.util.Log; -import android.view.KeyEvent; -import android.webkit.WebResourceError; -import android.webkit.WebResourceRequest; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.webkit.WebResourceErrorCompat; -import androidx.webkit.WebViewClientCompat; -import io.flutter.plugin.common.MethodChannel; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -// We need to use WebViewClientCompat to get -// shouldOverrideUrlLoading(WebView view, WebResourceRequest request) -// invoked by the webview on older Android devices, without it pages that use iframes will -// be broken when a navigationDelegate is set on Android version earlier than N. -class FlutterWebViewClient { - private static final String TAG = "FlutterWebViewClient"; - private final MethodChannel methodChannel; - private boolean hasNavigationDelegate; - boolean hasProgressTracking; - - FlutterWebViewClient(MethodChannel methodChannel) { - this.methodChannel = methodChannel; - } - - static String errorCodeToString(int errorCode) { - switch (errorCode) { - case WebViewClient.ERROR_AUTHENTICATION: - return "authentication"; - case WebViewClient.ERROR_BAD_URL: - return "badUrl"; - case WebViewClient.ERROR_CONNECT: - return "connect"; - case WebViewClient.ERROR_FAILED_SSL_HANDSHAKE: - return "failedSslHandshake"; - case WebViewClient.ERROR_FILE: - return "file"; - case WebViewClient.ERROR_FILE_NOT_FOUND: - return "fileNotFound"; - case WebViewClient.ERROR_HOST_LOOKUP: - return "hostLookup"; - case WebViewClient.ERROR_IO: - return "io"; - case WebViewClient.ERROR_PROXY_AUTHENTICATION: - return "proxyAuthentication"; - case WebViewClient.ERROR_REDIRECT_LOOP: - return "redirectLoop"; - case WebViewClient.ERROR_TIMEOUT: - return "timeout"; - case WebViewClient.ERROR_TOO_MANY_REQUESTS: - return "tooManyRequests"; - case WebViewClient.ERROR_UNKNOWN: - return "unknown"; - case WebViewClient.ERROR_UNSAFE_RESOURCE: - return "unsafeResource"; - case WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME: - return "unsupportedAuthScheme"; - case WebViewClient.ERROR_UNSUPPORTED_SCHEME: - return "unsupportedScheme"; - } - - final String message = - String.format(Locale.getDefault(), "Could not find a string for errorCode: %d", errorCode); - throw new IllegalArgumentException(message); - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - if (!hasNavigationDelegate) { - return false; - } - notifyOnNavigationRequest( - request.getUrl().toString(), request.getRequestHeaders(), view, request.isForMainFrame()); - // We must make a synchronous decision here whether to allow the navigation or not, - // if the Dart code has set a navigation delegate we want that delegate to decide whether - // to navigate or not, and as we cannot get a response from the Dart delegate synchronously we - // return true here to block the navigation, if the Dart delegate decides to allow the - // navigation the plugin will later make an addition loadUrl call for this url. - // - // Since we cannot call loadUrl for a subframe, we currently only allow the delegate to stop - // navigations that target the main frame, if the request is not for the main frame - // we just return false to allow the navigation. - // - // For more details see: https://github.com/flutter/flutter/issues/25329#issuecomment-464863209 - return request.isForMainFrame(); - } - - boolean shouldOverrideUrlLoading(WebView view, String url) { - if (!hasNavigationDelegate) { - return false; - } - // This version of shouldOverrideUrlLoading is only invoked by the webview on devices with - // webview versions earlier than 67(it is also invoked when hasNavigationDelegate is false). - // On these devices we cannot tell whether the navigation is targeted to the main frame or not. - // We proceed assuming that the navigation is targeted to the main frame. If the page had any - // frames they will be loaded in the main frame instead. - Log.w( - TAG, - "Using a navigationDelegate with an old webview implementation, pages with frames or iframes will not work"); - notifyOnNavigationRequest(url, null, view, true); - return true; - } - - /** - * Notifies the Flutter code that a download should start when a navigation delegate is set. - * - * @param view the webView the result of the navigation delegate will be send to. - * @param url the download url - * @return A boolean whether or not the request is forwarded to the Flutter code. - */ - boolean notifyDownload(WebView view, String url) { - if (!hasNavigationDelegate) { - return false; - } - - notifyOnNavigationRequest(url, null, view, true); - return true; - } - - private void onPageStarted(WebView view, String url) { - Map args = new HashMap<>(); - args.put("url", url); - methodChannel.invokeMethod("onPageStarted", args); - } - - private void onPageFinished(WebView view, String url) { - Map args = new HashMap<>(); - args.put("url", url); - methodChannel.invokeMethod("onPageFinished", args); - } - - void onLoadingProgress(int progress) { - if (hasProgressTracking) { - Map args = new HashMap<>(); - args.put("progress", progress); - methodChannel.invokeMethod("onProgress", args); - } - } - - private void onWebResourceError( - final int errorCode, final String description, final String failingUrl) { - final Map args = new HashMap<>(); - args.put("errorCode", errorCode); - args.put("description", description); - args.put("errorType", FlutterWebViewClient.errorCodeToString(errorCode)); - args.put("failingUrl", failingUrl); - methodChannel.invokeMethod("onWebResourceError", args); - } - - private void notifyOnNavigationRequest( - String url, Map headers, WebView webview, boolean isMainFrame) { - HashMap args = new HashMap<>(); - args.put("url", url); - args.put("isForMainFrame", isMainFrame); - if (isMainFrame) { - methodChannel.invokeMethod( - "navigationRequest", args, new OnNavigationRequestResult(url, headers, webview)); - } else { - methodChannel.invokeMethod("navigationRequest", args); - } - } - - // This method attempts to avoid using WebViewClientCompat due to bug - // https://bugs.chromium.org/p/chromium/issues/detail?id=925887. Also, see - // https://github.com/flutter/flutter/issues/29446. - WebViewClient createWebViewClient(boolean hasNavigationDelegate) { - this.hasNavigationDelegate = hasNavigationDelegate; - - if (!hasNavigationDelegate || android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return internalCreateWebViewClient(); - } - - return internalCreateWebViewClientCompat(); - } - - private WebViewClient internalCreateWebViewClient() { - return new WebViewClient() { - @TargetApi(Build.VERSION_CODES.N) - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, request); - } - - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - FlutterWebViewClient.this.onPageStarted(view, url); - } - - @Override - public void onPageFinished(WebView view, String url) { - FlutterWebViewClient.this.onPageFinished(view, url); - } - - @TargetApi(Build.VERSION_CODES.M) - @Override - public void onReceivedError( - WebView view, WebResourceRequest request, WebResourceError error) { - if (request.isForMainFrame()) { - FlutterWebViewClient.this.onWebResourceError( - error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); - } - } - - @Override - public void onReceivedError( - WebView view, int errorCode, String description, String failingUrl) { - FlutterWebViewClient.this.onWebResourceError(errorCode, description, failingUrl); - } - - @Override - public void onUnhandledKeyEvent(WebView view, KeyEvent event) { - // Deliberately empty. Occasionally the webview will mark events as having failed to be - // handled even though they were handled. We don't want to propagate those as they're not - // truly lost. - } - }; - } - - private WebViewClientCompat internalCreateWebViewClientCompat() { - return new WebViewClientCompat() { - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, request); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, url); - } - - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - FlutterWebViewClient.this.onPageStarted(view, url); - } - - @Override - public void onPageFinished(WebView view, String url) { - FlutterWebViewClient.this.onPageFinished(view, url); - } - - // This method is only called when the WebViewFeature.RECEIVE_WEB_RESOURCE_ERROR feature is - // enabled. The deprecated method is called when a device doesn't support this. - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - @SuppressLint("RequiresFeature") - @Override - public void onReceivedError( - @NonNull WebView view, - @NonNull WebResourceRequest request, - @NonNull WebResourceErrorCompat error) { - if (request.isForMainFrame()) { - FlutterWebViewClient.this.onWebResourceError( - error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); - } - } - - @Override - public void onReceivedError( - WebView view, int errorCode, String description, String failingUrl) { - FlutterWebViewClient.this.onWebResourceError(errorCode, description, failingUrl); - } - - @Override - public void onUnhandledKeyEvent(WebView view, KeyEvent event) { - // Deliberately empty. Occasionally the webview will mark events as having failed to be - // handled even though they were handled. We don't want to propagate those as they're not - // truly lost. - } - }; - } - - private static class OnNavigationRequestResult implements MethodChannel.Result { - private final String url; - private final Map headers; - private final WebView webView; - - private OnNavigationRequestResult(String url, Map headers, WebView webView) { - this.url = url; - this.headers = headers; - this.webView = webView; - } - - @Override - public void success(Object shouldLoad) { - Boolean typedShouldLoad = (Boolean) shouldLoad; - if (typedShouldLoad) { - loadUrl(); - } - } - - @Override - public void error(String errorCode, String s1, Object o) { - throw new IllegalStateException("navigationRequest calls must succeed"); - } - - @Override - public void notImplemented() { - throw new IllegalStateException( - "navigationRequest must be implemented by the webview method channel"); - } - - private void loadUrl() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - webView.loadUrl(url, headers); - } else { - webView.loadUrl(url); - } - } - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java deleted file mode 100644 index 8fe58104a0fb..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.content.Context; -import android.view.View; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.StandardMessageCodec; -import io.flutter.plugin.platform.PlatformView; -import io.flutter.plugin.platform.PlatformViewFactory; -import java.util.Map; - -public final class FlutterWebViewFactory extends PlatformViewFactory { - private final BinaryMessenger messenger; - private final View containerView; - - FlutterWebViewFactory(BinaryMessenger messenger, View containerView) { - super(StandardMessageCodec.INSTANCE); - this.messenger = messenger; - this.containerView = containerView; - } - - @SuppressWarnings("unchecked") - @Override - public PlatformView create(Context context, int id, Object args) { - Map params = (Map) args; - MethodChannel methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id); - return new FlutterWebView(context, methodChannel, params, containerView); - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java deleted file mode 100644 index 51b2a3809fff..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static android.content.Context.INPUT_METHOD_SERVICE; - -import android.content.Context; -import android.graphics.Rect; -import android.os.Build; -import android.util.Log; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.webkit.WebView; -import android.widget.ListPopupWindow; - -/** - * A WebView subclass that mirrors the same implementation hacks that the system WebView does in - * order to correctly create an InputConnection. - * - *

    These hacks are only needed in Android versions below N and exist to create an InputConnection - * on the WebView's dedicated input, or IME, thread. The majority of this proxying logic is in - * {@link #checkInputConnectionProxy}. - * - *

    See also {@link ThreadedInputConnectionProxyAdapterView}. - */ -final class InputAwareWebView extends WebView { - private static final String TAG = "InputAwareWebView"; - private View threadedInputConnectionProxyView; - private ThreadedInputConnectionProxyAdapterView proxyAdapterView; - private View containerView; - - InputAwareWebView(Context context, View containerView) { - super(context); - this.containerView = containerView; - } - - void setContainerView(View containerView) { - this.containerView = containerView; - - if (proxyAdapterView == null) { - return; - } - - Log.w(TAG, "The containerView has changed while the proxyAdapterView exists."); - if (containerView != null) { - setInputConnectionTarget(proxyAdapterView); - } - } - - /** - * Set our proxy adapter view to use its cached input connection instead of creating new ones. - * - *

    This is used to avoid losing our input connection when the virtual display is resized. - */ - void lockInputConnection() { - if (proxyAdapterView == null) { - return; - } - - proxyAdapterView.setLocked(true); - } - - /** Sets the proxy adapter view back to its default behavior. */ - void unlockInputConnection() { - if (proxyAdapterView == null) { - return; - } - - proxyAdapterView.setLocked(false); - } - - /** Restore the original InputConnection, if needed. */ - void dispose() { - resetInputConnection(); - } - - /** - * Creates an InputConnection from the IME thread when needed. - * - *

    We only need to create a {@link ThreadedInputConnectionProxyAdapterView} and create an - * InputConnectionProxy on the IME thread when WebView is doing the same thing. So we rely on the - * system calling this method for WebView's proxy view in order to know when we need to create our - * own. - * - *

    This method would normally be called for any View that used the InputMethodManager. We rely - * on flutter/engine filtering the calls we receive down to the ones in our hierarchy and the - * system WebView in order to know whether or not the system WebView expects an InputConnection on - * the IME thread. - */ - @Override - public boolean checkInputConnectionProxy(final View view) { - // Check to see if the view param is WebView's ThreadedInputConnectionProxyView. - View previousProxy = threadedInputConnectionProxyView; - threadedInputConnectionProxyView = view; - if (previousProxy == view) { - // This isn't a new ThreadedInputConnectionProxyView. Ignore it. - return super.checkInputConnectionProxy(view); - } - if (containerView == null) { - Log.e( - TAG, - "Can't create a proxy view because there's no container view. Text input may not work."); - return super.checkInputConnectionProxy(view); - } - - // We've never seen this before, so we make the assumption that this is WebView's - // ThreadedInputConnectionProxyView. We are making the assumption that the only view that could - // possibly be interacting with the IMM here is WebView's ThreadedInputConnectionProxyView. - proxyAdapterView = - new ThreadedInputConnectionProxyAdapterView( - /*containerView=*/ containerView, - /*targetView=*/ view, - /*imeHandler=*/ view.getHandler()); - setInputConnectionTarget(/*targetView=*/ proxyAdapterView); - return super.checkInputConnectionProxy(view); - } - - /** - * Ensure that input creation happens back on {@link #containerView}'s thread once this view no - * longer has focus. - * - *

    The logic in {@link #checkInputConnectionProxy} forces input creation to happen on Webview's - * thread for all connections. We undo it here so users will be able to go back to typing in - * Flutter UIs as expected. - */ - @Override - public void clearFocus() { - super.clearFocus(); - resetInputConnection(); - } - - /** - * Ensure that input creation happens back on {@link #containerView}. - * - *

    The logic in {@link #checkInputConnectionProxy} forces input creation to happen on Webview's - * thread for all connections. We undo it here so users will be able to go back to typing in - * Flutter UIs as expected. - */ - private void resetInputConnection() { - if (proxyAdapterView == null) { - // No need to reset the InputConnection to the default thread if we've never changed it. - return; - } - if (containerView == null) { - Log.e(TAG, "Can't reset the input connection to the container view because there is none."); - return; - } - setInputConnectionTarget(/*targetView=*/ containerView); - } - - /** - * This is the crucial trick that gets the InputConnection creation to happen on the correct - * thread pre Android N. - * https://cs.chromium.org/chromium/src/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnectionFactory.java?l=169&rcl=f0698ee3e4483fad5b0c34159276f71cfaf81f3a - * - *

    {@code targetView} should have a {@link View#getHandler} method with the thread that future - * InputConnections should be created on. - */ - private void setInputConnectionTarget(final View targetView) { - if (containerView == null) { - Log.e( - TAG, - "Can't set the input connection target because there is no containerView to use as a handler."); - return; - } - - targetView.requestFocus(); - containerView.post( - new Runnable() { - @Override - public void run() { - InputMethodManager imm = - (InputMethodManager) getContext().getSystemService(INPUT_METHOD_SERVICE); - // This is a hack to make InputMethodManager believe that the target view now has focus. - // As a result, InputMethodManager will think that targetView is focused, and will call - // getHandler() of the view when creating input connection. - - // Step 1: Set targetView as InputMethodManager#mNextServedView. This does not affect - // the real window focus. - targetView.onWindowFocusChanged(true); - - // Step 2: Have InputMethodManager focus in on targetView. As a result, IMM will call - // onCreateInputConnection() on targetView on the same thread as - // targetView.getHandler(). It will also call subsequent InputConnection methods on this - // thread. This is the IME thread in cases where targetView is our proxyAdapterView. - imm.isActive(containerView); - } - }); - } - - @Override - protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { - // This works around a crash when old (<67.0.3367.0) Chromium versions are used. - - // Prior to Chromium 67.0.3367 the following sequence happens when a select drop down is shown - // on tablets: - // - // - WebView is calling ListPopupWindow#show - // - buildDropDown is invoked, which sets mDropDownList to a DropDownListView. - // - showAsDropDown is invoked - resulting in mDropDownList being added to the window and is - // also synchronously performing the following sequence: - // - WebView's focus change listener is loosing focus (as mDropDownList got it) - // - WebView is hiding all popups (as it lost focus) - // - WebView's SelectPopupDropDown#hide is invoked. - // - DropDownPopupWindow#dismiss is invoked setting mDropDownList to null. - // - mDropDownList#setSelection is invoked and is throwing a NullPointerException (as we just set mDropDownList to null). - // - // To workaround this, we drop the problematic focus lost call. - // See more details on: https://github.com/flutter/flutter/issues/54164 - // - // We don't do this after Android P as it shipped with a new enough WebView version, and it's - // better to not do this on all future Android versions in case DropDownListView's code changes. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P - && isCalledFromListPopupWindowShow() - && !focused) { - return; - } - super.onFocusChanged(focused, direction, previouslyFocusedRect); - } - - private boolean isCalledFromListPopupWindowShow() { - StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace(); - for (StackTraceElement stackTraceElement : stackTraceElements) { - if (stackTraceElement.getClassName().equals(ListPopupWindow.class.getCanonicalName()) - && stackTraceElement.getMethodName().equals("show")) { - return true; - } - } - return false; - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java deleted file mode 100644 index 4d596351b3d0..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannel.java +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.os.Handler; -import android.os.Looper; -import android.webkit.JavascriptInterface; -import io.flutter.plugin.common.MethodChannel; -import java.util.HashMap; - -/** - * Added as a JavaScript interface to the WebView for any JavaScript channel that the Dart code sets - * up. - * - *

    Exposes a single method named `postMessage` to JavaScript, which sends a message over a method - * channel to the Dart code. - */ -class JavaScriptChannel { - private final MethodChannel methodChannel; - private final String javaScriptChannelName; - private final Handler platformThreadHandler; - - /** - * @param methodChannel the Flutter WebView method channel to which JS messages are sent - * @param javaScriptChannelName the name of the JavaScript channel, this is sent over the method - * channel with each message to let the Dart code know which JavaScript channel the message - * was sent through - */ - JavaScriptChannel( - MethodChannel methodChannel, String javaScriptChannelName, Handler platformThreadHandler) { - this.methodChannel = methodChannel; - this.javaScriptChannelName = javaScriptChannelName; - this.platformThreadHandler = platformThreadHandler; - } - - // Suppressing unused warning as this is invoked from JavaScript. - @SuppressWarnings("unused") - @JavascriptInterface - public void postMessage(final String message) { - Runnable postMessageRunnable = - new Runnable() { - @Override - public void run() { - HashMap arguments = new HashMap<>(); - arguments.put("channel", javaScriptChannelName); - arguments.put("message", message); - methodChannel.invokeMethod("javascriptChannelMessage", arguments); - } - }; - if (platformThreadHandler.getLooper() == Looper.myLooper()) { - postMessageRunnable.run(); - } else { - platformThreadHandler.post(postMessageRunnable); - } - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java deleted file mode 100644 index 1c865c9444e2..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.os.Handler; -import android.os.IBinder; -import android.view.View; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; - -/** - * A fake View only exposed to InputMethodManager. - * - *

    This follows a similar flow to Chromium's WebView (see - * https://cs.chromium.org/chromium/src/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnectionProxyView.java). - * WebView itself bounces its InputConnection around several different threads. We follow its logic - * here to get the same working connection. - * - *

    This exists solely to forward input creation to WebView's ThreadedInputConnectionProxyView on - * the IME thread. The way that this is created in {@link - * InputAwareWebView#checkInputConnectionProxy} guarantees that we have a handle to - * ThreadedInputConnectionProxyView and {@link #onCreateInputConnection} is always called on the IME - * thread. We delegate to ThreadedInputConnectionProxyView there to get WebView's input connection. - */ -final class ThreadedInputConnectionProxyAdapterView extends View { - final Handler imeHandler; - final IBinder windowToken; - final View containerView; - final View rootView; - final View targetView; - - private boolean triggerDelayed = true; - private boolean isLocked = false; - private InputConnection cachedConnection; - - ThreadedInputConnectionProxyAdapterView(View containerView, View targetView, Handler imeHandler) { - super(containerView.getContext()); - this.imeHandler = imeHandler; - this.containerView = containerView; - this.targetView = targetView; - windowToken = containerView.getWindowToken(); - rootView = containerView.getRootView(); - setFocusable(true); - setFocusableInTouchMode(true); - setVisibility(VISIBLE); - } - - /** Returns whether or not this is currently asynchronously acquiring an input connection. */ - boolean isTriggerDelayed() { - return triggerDelayed; - } - - /** Sets whether or not this should use its previously cached input connection. */ - void setLocked(boolean locked) { - isLocked = locked; - } - - /** - * This is expected to be called on the IME thread. See the setup required for this in {@link - * InputAwareWebView#checkInputConnectionProxy(View)}. - * - *

    Delegates to ThreadedInputConnectionProxyView to get WebView's input connection. - */ - @Override - public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { - triggerDelayed = false; - InputConnection inputConnection = - (isLocked) ? cachedConnection : targetView.onCreateInputConnection(outAttrs); - triggerDelayed = true; - cachedConnection = inputConnection; - return inputConnection; - } - - @Override - public boolean checkInputConnectionProxy(View view) { - return true; - } - - @Override - public boolean hasWindowFocus() { - // None of our views here correctly report they have window focus because of how we're embedding - // the platform view inside of a virtual display. - return true; - } - - @Override - public View getRootView() { - return rootView; - } - - @Override - public boolean onCheckIsTextEditor() { - return true; - } - - @Override - public boolean isFocused() { - return true; - } - - @Override - public IBinder getWindowToken() { - return windowToken; - } - - @Override - public Handler getHandler() { - return imeHandler; - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java deleted file mode 100644 index d3cd1d57cdae..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import android.content.Context; -import android.view.View; -import android.webkit.DownloadListener; -import android.webkit.WebChromeClient; -import android.webkit.WebSettings; -import android.webkit.WebView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** Builder used to create {@link android.webkit.WebView} objects. */ -public class WebViewBuilder { - - /** Factory used to create a new {@link android.webkit.WebView} instance. */ - static class WebViewFactory { - - /** - * Creates a new {@link android.webkit.WebView} instance. - * - * @param context an Activity Context to access application assets. This value cannot be null. - * @param usesHybridComposition If {@code false} a {@link InputAwareWebView} instance is - * returned. - * @param containerView must be supplied when the {@code useHybridComposition} parameter is set - * to {@code false}. Used to create an InputConnection on the WebView's dedicated input, or - * IME, thread (see also {@link InputAwareWebView}) - * @return A new instance of the {@link android.webkit.WebView} object. - */ - static WebView create(Context context, boolean usesHybridComposition, View containerView) { - return usesHybridComposition - ? new WebView(context) - : new InputAwareWebView(context, containerView); - } - } - - private final Context context; - private final View containerView; - - private boolean enableDomStorage; - private boolean javaScriptCanOpenWindowsAutomatically; - private boolean supportMultipleWindows; - private boolean usesHybridComposition; - private WebChromeClient webChromeClient; - private DownloadListener downloadListener; - - /** - * Constructs a new {@link WebViewBuilder} object with a custom implementation of the {@link - * WebViewFactory} object. - * - * @param context an Activity Context to access application assets. This value cannot be null. - * @param containerView must be supplied when the {@code useHybridComposition} parameter is set to - * {@code false}. Used to create an InputConnection on the WebView's dedicated input, or IME, - * thread (see also {@link InputAwareWebView}) - */ - WebViewBuilder(@NonNull final Context context, View containerView) { - this.context = context; - this.containerView = containerView; - } - - /** - * Sets whether the DOM storage API is enabled. The default value is {@code false}. - * - * @param flag {@code true} is {@link android.webkit.WebView} should use the DOM storage API. - * @return This builder. This value cannot be {@code null}. - */ - public WebViewBuilder setDomStorageEnabled(boolean flag) { - this.enableDomStorage = flag; - return this; - } - - /** - * Sets whether JavaScript is allowed to open windows automatically. This applies to the - * JavaScript function {@code window.open()}. The default value is {@code false}. - * - * @param flag {@code true} if JavaScript is allowed to open windows automatically. - * @return This builder. This value cannot be {@code null}. - */ - public WebViewBuilder setJavaScriptCanOpenWindowsAutomatically(boolean flag) { - this.javaScriptCanOpenWindowsAutomatically = flag; - return this; - } - - /** - * Sets whether the {@link WebView} supports multiple windows. If set to {@code true}, {@link - * WebChromeClient#onCreateWindow} must be implemented by the host application. The default is - * {@code false}. - * - * @param flag {@code true} if multiple windows are supported. - * @return This builder. This value cannot be {@code null}. - */ - public WebViewBuilder setSupportMultipleWindows(boolean flag) { - this.supportMultipleWindows = flag; - return this; - } - - /** - * Sets whether the hybrid composition should be used. - * - *

    If set to {@code true} a standard {@link WebView} is created. If set to {@code false} the - * {@link WebViewBuilder} will create a {@link InputAwareWebView} to workaround issues using the - * {@link WebView} on Android versions below N. - * - * @param flag {@code true} if uses hybrid composition. The default is {@code false}. - * @return This builder. This value cannot be {@code null} - */ - public WebViewBuilder setUsesHybridComposition(boolean flag) { - this.usesHybridComposition = flag; - return this; - } - - /** - * Sets the chrome handler. This is an implementation of WebChromeClient for use in handling - * JavaScript dialogs, favicons, titles, and the progress. This will replace the current handler. - * - * @param webChromeClient an implementation of WebChromeClient This value may be null. - * @return This builder. This value cannot be {@code null}. - */ - public WebViewBuilder setWebChromeClient(@Nullable WebChromeClient webChromeClient) { - this.webChromeClient = webChromeClient; - return this; - } - - /** - * Registers the interface to be used when content can not be handled by the rendering engine, and - * should be downloaded instead. This will replace the current handler. - * - * @param downloadListener an implementation of DownloadListener This value may be null. - * @return This builder. This value cannot be {@code null}. - */ - public WebViewBuilder setDownloadListener(@Nullable DownloadListener downloadListener) { - this.downloadListener = downloadListener; - return this; - } - - /** - * Build the {@link android.webkit.WebView} using the current settings. - * - * @return The {@link android.webkit.WebView} using the current settings. - */ - public WebView build() { - WebView webView = WebViewFactory.create(context, usesHybridComposition, containerView); - - WebSettings webSettings = webView.getSettings(); - webSettings.setDomStorageEnabled(enableDomStorage); - webSettings.setJavaScriptCanOpenWindowsAutomatically(javaScriptCanOpenWindowsAutomatically); - webSettings.setSupportMultipleWindows(supportMultipleWindows); - webView.setWebChromeClient(webChromeClient); - webView.setDownloadListener(downloadListener); - return webView; - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java deleted file mode 100644 index 268d35a1e04c..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import io.flutter.embedding.engine.plugins.FlutterPlugin; -import io.flutter.plugin.common.BinaryMessenger; - -/** - * Java platform implementation of the webview_flutter plugin. - * - *

    Register this in an add to app scenario to gracefully handle activity and context changes. - * - *

    Call {@link #registerWith(Registrar)} to use the stable {@code io.flutter.plugin.common} - * package instead. - */ -public class WebViewFlutterPlugin implements FlutterPlugin { - - private FlutterCookieManager flutterCookieManager; - - /** - * Add an instance of this to {@link io.flutter.embedding.engine.plugins.PluginRegistry} to - * register it. - * - *

    THIS PLUGIN CODE PATH DEPENDS ON A NEWER VERSION OF FLUTTER THAN THE ONE DEFINED IN THE - * PUBSPEC.YAML. Text input will fail on some Android devices unless this is used with at least - * flutter/flutter@1d4d63ace1f801a022ea9ec737bf8c15395588b9. Use the V1 embedding with {@link - * #registerWith(Registrar)} to use this plugin with older Flutter versions. - * - *

    Registration should eventually be handled automatically by v2 of the - * GeneratedPluginRegistrant. https://github.com/flutter/flutter/issues/42694 - */ - public WebViewFlutterPlugin() {} - - /** - * Registers a plugin implementation that uses the stable {@code io.flutter.plugin.common} - * package. - * - *

    Calling this automatically initializes the plugin. However plugins initialized this way - * won't react to changes in activity or context, unlike {@link CameraPlugin}. - */ - @SuppressWarnings("deprecation") - public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) { - registrar - .platformViewRegistry() - .registerViewFactory( - "plugins.flutter.io/webview", - new FlutterWebViewFactory(registrar.messenger(), registrar.view())); - new FlutterCookieManager(registrar.messenger()); - } - - @Override - public void onAttachedToEngine(FlutterPluginBinding binding) { - BinaryMessenger messenger = binding.getBinaryMessenger(); - binding - .getPlatformViewRegistry() - .registerViewFactory( - "plugins.flutter.io/webview", - new FlutterWebViewFactory(messenger, /*containerView=*/ null)); - flutterCookieManager = new FlutterCookieManager(messenger); - } - - @Override - public void onDetachedFromEngine(FlutterPluginBinding binding) { - if (flutterCookieManager == null) { - return; - } - - flutterCookieManager.dispose(); - flutterCookieManager = null; - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java deleted file mode 100644 index 2c918584ba83..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import android.webkit.WebView; -import org.junit.Before; -import org.junit.Test; - -public class FlutterDownloadListenerTest { - private FlutterWebViewClient webViewClient; - private WebView webView; - - @Before - public void before() { - webViewClient = mock(FlutterWebViewClient.class); - webView = mock(WebView.class); - } - - @Test - public void onDownloadStart_should_notify_webViewClient() { - String url = "testurl.com"; - FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); - downloadListener.onDownloadStart(url, "test", "inline", "data/text", 0); - verify(webViewClient).notifyDownload(nullable(WebView.class), eq(url)); - } - - @Test - public void onDownloadStart_should_pass_webView() { - FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); - downloadListener.setWebView(webView); - downloadListener.onDownloadStart("testurl.com", "test", "inline", "data/text", 0); - verify(webViewClient).notifyDownload(eq(webView), anyString()); - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java deleted file mode 100644 index 86346ac08f16..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import android.webkit.WebView; -import io.flutter.plugin.common.MethodChannel; -import java.util.HashMap; -import org.junit.Before; -import org.junit.Test; -import org.mockito.ArgumentCaptor; - -public class FlutterWebViewClientTest { - - MethodChannel mockMethodChannel; - WebView mockWebView; - - @Before - public void before() { - mockMethodChannel = mock(MethodChannel.class); - mockWebView = mock(WebView.class); - } - - @Test - public void notify_download_should_notifyOnNavigationRequest_when_navigationDelegate_is_set() { - final String url = "testurl.com"; - - FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); - client.createWebViewClient(true); - - client.notifyDownload(mockWebView, url); - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Object.class); - verify(mockMethodChannel) - .invokeMethod( - eq("navigationRequest"), argumentCaptor.capture(), any(MethodChannel.Result.class)); - HashMap map = (HashMap) argumentCaptor.getValue(); - assertEquals(map.get("url"), url); - assertEquals(map.get("isForMainFrame"), true); - } - - @Test - public void - notify_download_should_not_notifyOnNavigationRequest_when_navigationDelegate_is_not_set() { - final String url = "testurl.com"; - - FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); - client.createWebViewClient(false); - - client.notifyDownload(mockWebView, url); - verifyNoInteractions(mockMethodChannel); - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java deleted file mode 100644 index 56d9db1ee493..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.webkit.DownloadListener; -import android.webkit.WebChromeClient; -import android.webkit.WebView; -import java.util.HashMap; -import java.util.Map; -import org.junit.Before; -import org.junit.Test; - -public class FlutterWebViewTest { - private WebChromeClient mockWebChromeClient; - private DownloadListener mockDownloadListener; - private WebViewBuilder mockWebViewBuilder; - private WebView mockWebView; - - @Before - public void before() { - mockWebChromeClient = mock(WebChromeClient.class); - mockWebViewBuilder = mock(WebViewBuilder.class); - mockWebView = mock(WebView.class); - mockDownloadListener = mock(DownloadListener.class); - - when(mockWebViewBuilder.setDomStorageEnabled(anyBoolean())).thenReturn(mockWebViewBuilder); - when(mockWebViewBuilder.setJavaScriptCanOpenWindowsAutomatically(anyBoolean())) - .thenReturn(mockWebViewBuilder); - when(mockWebViewBuilder.setSupportMultipleWindows(anyBoolean())).thenReturn(mockWebViewBuilder); - when(mockWebViewBuilder.setUsesHybridComposition(anyBoolean())).thenReturn(mockWebViewBuilder); - when(mockWebViewBuilder.setWebChromeClient(any(WebChromeClient.class))) - .thenReturn(mockWebViewBuilder); - when(mockWebViewBuilder.setDownloadListener(any(DownloadListener.class))) - .thenReturn(mockWebViewBuilder); - - when(mockWebViewBuilder.build()).thenReturn(mockWebView); - } - - @Test - public void createWebView_should_create_webview_with_default_configuration() { - FlutterWebView.createWebView( - mockWebViewBuilder, createParameterMap(false), mockWebChromeClient, mockDownloadListener); - - verify(mockWebViewBuilder, times(1)).setDomStorageEnabled(true); - verify(mockWebViewBuilder, times(1)).setJavaScriptCanOpenWindowsAutomatically(true); - verify(mockWebViewBuilder, times(1)).setSupportMultipleWindows(true); - verify(mockWebViewBuilder, times(1)).setUsesHybridComposition(false); - verify(mockWebViewBuilder, times(1)).setWebChromeClient(mockWebChromeClient); - } - - private Map createParameterMap(boolean usesHybridComposition) { - Map params = new HashMap<>(); - params.put("usesHybridComposition", usesHybridComposition); - - return params; - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java deleted file mode 100644 index 423cb210c392..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static org.junit.Assert.assertNotNull; -import static org.mockito.Mockito.*; - -import android.content.Context; -import android.view.View; -import android.webkit.DownloadListener; -import android.webkit.WebChromeClient; -import android.webkit.WebSettings; -import android.webkit.WebView; -import io.flutter.plugins.webviewflutter.WebViewBuilder.WebViewFactory; -import java.io.IOException; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.mockito.MockedStatic; -import org.mockito.MockedStatic.Verification; - -public class WebViewBuilderTest { - private Context mockContext; - private View mockContainerView; - private WebView mockWebView; - private MockedStatic mockedStaticWebViewFactory; - - @Before - public void before() { - mockContext = mock(Context.class); - mockContainerView = mock(View.class); - mockWebView = mock(WebView.class); - mockedStaticWebViewFactory = mockStatic(WebViewFactory.class); - - mockedStaticWebViewFactory - .when( - new Verification() { - @Override - public void apply() { - WebViewFactory.create(mockContext, false, mockContainerView); - } - }) - .thenReturn(mockWebView); - } - - @After - public void after() { - mockedStaticWebViewFactory.close(); - } - - @Test - public void ctor_test() { - WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); - - assertNotNull(builder); - } - - @Test - public void build_should_set_values() throws IOException { - WebSettings mockWebSettings = mock(WebSettings.class); - WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); - DownloadListener mockDownloadListener = mock(DownloadListener.class); - - when(mockWebView.getSettings()).thenReturn(mockWebSettings); - - WebViewBuilder builder = - new WebViewBuilder(mockContext, mockContainerView) - .setDomStorageEnabled(true) - .setJavaScriptCanOpenWindowsAutomatically(true) - .setSupportMultipleWindows(true) - .setWebChromeClient(mockWebChromeClient) - .setDownloadListener(mockDownloadListener); - - WebView webView = builder.build(); - - assertNotNull(webView); - verify(mockWebSettings).setDomStorageEnabled(true); - verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(true); - verify(mockWebSettings).setSupportMultipleWindows(true); - verify(mockWebView).setWebChromeClient(mockWebChromeClient); - verify(mockWebView).setDownloadListener(mockDownloadListener); - } - - @Test - public void build_should_use_default_values() throws IOException { - WebSettings mockWebSettings = mock(WebSettings.class); - WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); - - when(mockWebView.getSettings()).thenReturn(mockWebSettings); - - WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); - - WebView webView = builder.build(); - - assertNotNull(webView); - verify(mockWebSettings).setDomStorageEnabled(false); - verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false); - verify(mockWebSettings).setSupportMultipleWindows(false); - verify(mockWebView).setWebChromeClient(null); - verify(mockWebView).setDownloadListener(null); - } -} diff --git a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java b/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java deleted file mode 100644 index 131a5a3eb53a..000000000000 --- a/packages/webview_flutter/webview_flutter/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2013 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. - -package io.flutter.plugins.webviewflutter; - -import static org.junit.Assert.assertEquals; - -import android.webkit.WebViewClient; -import org.junit.Test; - -public class WebViewTest { - @Test - public void errorCodes() { - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_AUTHENTICATION), - "authentication"); - assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_BAD_URL), "badUrl"); - assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_CONNECT), "connect"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FAILED_SSL_HANDSHAKE), - "failedSslHandshake"); - assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE), "file"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE_NOT_FOUND), "fileNotFound"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_HOST_LOOKUP), "hostLookup"); - assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_IO), "io"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_PROXY_AUTHENTICATION), - "proxyAuthentication"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_REDIRECT_LOOP), "redirectLoop"); - assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TIMEOUT), "timeout"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TOO_MANY_REQUESTS), - "tooManyRequests"); - assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNKNOWN), "unknown"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSAFE_RESOURCE), - "unsafeResource"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME), - "unsupportedAuthScheme"); - assertEquals( - FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_SCHEME), - "unsupportedScheme"); - } -} diff --git a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index 0e128caa8f32..a6211b2dae75 100644 --- a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -18,6 +18,8 @@ import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const bool _skipDueToIssue86757 = true; + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('initialUrl', (WidgetTester tester) async { final Completer controllerCompleter = @@ -37,7 +39,7 @@ void main() { final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); expect(currentUrl, 'https://flutter.dev/'); - }, skip: true); + }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('loadUrl', (WidgetTester tester) async { @@ -59,7 +61,7 @@ void main() { await controller.loadUrl('https://www.google.com/'); final String? currentUrl = await controller.currentUrl(); expect(currentUrl, 'https://www.google.com/'); - }, skip: true); + }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('loadUrl with headers', (WidgetTester tester) async { @@ -101,7 +103,7 @@ void main() { final String content = await controller .evaluateJavascript('document.documentElement.innerText'); expect(content.contains('flutter_test_header'), isTrue); - }, skip: Platform.isAndroid); + }, skip: Platform.isAndroid && _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('JavaScriptChannel', (WidgetTester tester) async { @@ -150,7 +152,7 @@ void main() { // https://github.com/flutter/flutter/issues/66318 await controller.evaluateJavascript('Echo.postMessage("hello");1;'); expect(messagesReceived, equals(['hello'])); - }, skip: Platform.isAndroid); + }, skip: Platform.isAndroid && _skipDueToIssue86757); testWidgets('resize webview', (WidgetTester tester) async { final String resizeTest = ''' @@ -328,7 +330,7 @@ void main() { final String customUserAgent2 = await _getUserAgent(controller); expect(customUserAgent2, defaultPlatformUserAgent); - }, skip: Platform.isAndroid); + }, skip: Platform.isAndroid && _skipDueToIssue86757); group('Video playback policy', () { late String videoTestBase64; @@ -877,7 +879,7 @@ void main() { scrollPosY = await controller.getScrollY(); expect(scrollPosX, X_SCROLL * 2); expect(scrollPosY, Y_SCROLL * 2); - }, skip: Platform.isAndroid); + }, skip: Platform.isAndroid && _skipDueToIssue86757); }); group('SurfaceAndroidWebView', () { @@ -956,7 +958,7 @@ void main() { scrollPosY = await controller.getScrollY(); expect(X_SCROLL * 2, scrollPosX); expect(Y_SCROLL * 2, scrollPosY); - }, skip: true); + }, skip: !Platform.isAndroid || _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets('inputs are scrolled into view when focused', @@ -1062,7 +1064,7 @@ void main() { lastInputClientRectRelativeToViewport['right'] <= viewportRectRelativeToViewport['right'], isTrue); - }, skip: true); + }, skip: !Platform.isAndroid || _skipDueToIssue86757); }); group('NavigationDelegate', () { @@ -1332,7 +1334,7 @@ void main() { expect(currentUrl, 'https://flutter.dev/'); }, // Flaky on Android: https://github.com/flutter/flutter/issues/86757 - skip: Platform.isAndroid); + skip: Platform.isAndroid && _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. testWidgets( @@ -1373,7 +1375,7 @@ void main() { await pageLoaded.future; expect(controller.currentUrl(), completion('https://flutter.dev/')); }, - skip: true, + skip: _skipDueToIssue86757, ); testWidgets( diff --git a/packages/webview_flutter/webview_flutter/ios/Assets/.gitkeep b/packages/webview_flutter/webview_flutter/ios/Assets/.gitkeep deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.h deleted file mode 100644 index 8fe331875250..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.h +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2013 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 -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FLTCookieManager : NSObject - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m deleted file mode 100644 index eb7c856b250d..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTCookieManager.m +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2013 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 "FLTCookieManager.h" - -@implementation FLTCookieManager { -} - -+ (void)registerWithRegistrar:(NSObject *)registrar { - FLTCookieManager *instance = [[FLTCookieManager alloc] init]; - - FlutterMethodChannel *channel = - [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/cookie_manager" - binaryMessenger:[registrar messenger]]; - [registrar addMethodCallDelegate:instance channel:channel]; -} - -- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { - if ([[call method] isEqualToString:@"clearCookies"]) { - [self clearCookies:result]; - } else { - result(FlutterMethodNotImplemented); - } -} - -- (void)clearCookies:(FlutterResult)result { - NSSet *websiteDataTypes = [NSSet setWithObject:WKWebsiteDataTypeCookies]; - WKWebsiteDataStore *dataStore = [WKWebsiteDataStore defaultDataStore]; - - void (^deleteAndNotify)(NSArray *) = - ^(NSArray *cookies) { - BOOL hasCookies = cookies.count > 0; - [dataStore removeDataOfTypes:websiteDataTypes - forDataRecords:cookies - completionHandler:^{ - result(@(hasCookies)); - }]; - }; - - [dataStore fetchDataRecordsOfTypes:websiteDataTypes completionHandler:deleteAndNotify]; -} - -@end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h deleted file mode 100644 index 31edadc8cc05..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.h +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2013 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 -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FLTWKNavigationDelegate : NSObject - -- (instancetype)initWithChannel:(FlutterMethodChannel*)channel; - -/** - * Whether to delegate navigation decisions over the method channel. - */ -@property(nonatomic, assign) BOOL hasDartNavigationDelegate; - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m deleted file mode 100644 index 8b7ee7d0cfb7..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKNavigationDelegate.m +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2013 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 "FLTWKNavigationDelegate.h" - -@implementation FLTWKNavigationDelegate { - FlutterMethodChannel *_methodChannel; -} - -- (instancetype)initWithChannel:(FlutterMethodChannel *)channel { - self = [super init]; - if (self) { - _methodChannel = channel; - } - return self; -} - -#pragma mark - WKNavigationDelegate conformance - -- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { - [_methodChannel invokeMethod:@"onPageStarted" arguments:@{@"url" : webView.URL.absoluteString}]; -} - -- (void)webView:(WKWebView *)webView - decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction - decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { - if (!self.hasDartNavigationDelegate) { - decisionHandler(WKNavigationActionPolicyAllow); - return; - } - NSDictionary *arguments = @{ - @"url" : navigationAction.request.URL.absoluteString, - @"isForMainFrame" : @(navigationAction.targetFrame.isMainFrame) - }; - [_methodChannel invokeMethod:@"navigationRequest" - arguments:arguments - result:^(id _Nullable result) { - if ([result isKindOfClass:[FlutterError class]]) { - NSLog(@"navigationRequest has unexpectedly completed with an error, " - @"allowing navigation."); - decisionHandler(WKNavigationActionPolicyAllow); - return; - } - if (result == FlutterMethodNotImplemented) { - NSLog(@"navigationRequest was unexepectedly not implemented: %@, " - @"allowing navigation.", - result); - decisionHandler(WKNavigationActionPolicyAllow); - return; - } - if (![result isKindOfClass:[NSNumber class]]) { - NSLog(@"navigationRequest unexpectedly returned a non boolean value: " - @"%@, allowing navigation.", - result); - decisionHandler(WKNavigationActionPolicyAllow); - return; - } - NSNumber *typedResult = result; - decisionHandler([typedResult boolValue] ? WKNavigationActionPolicyAllow - : WKNavigationActionPolicyCancel); - }]; -} - -- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { - [_methodChannel invokeMethod:@"onPageFinished" arguments:@{@"url" : webView.URL.absoluteString}]; -} - -+ (id)errorCodeToString:(NSUInteger)code { - switch (code) { - case WKErrorUnknown: - return @"unknown"; - case WKErrorWebContentProcessTerminated: - return @"webContentProcessTerminated"; - case WKErrorWebViewInvalidated: - return @"webViewInvalidated"; - case WKErrorJavaScriptExceptionOccurred: - return @"javaScriptExceptionOccurred"; - case WKErrorJavaScriptResultTypeIsUnsupported: - return @"javaScriptResultTypeIsUnsupported"; - } - - return [NSNull null]; -} - -- (void)onWebResourceError:(NSError *)error { - [_methodChannel invokeMethod:@"onWebResourceError" - arguments:@{ - @"errorCode" : @(error.code), - @"domain" : error.domain, - @"description" : error.description, - @"errorType" : [FLTWKNavigationDelegate errorCodeToString:error.code], - }]; -} - -- (void)webView:(WKWebView *)webView - didFailNavigation:(WKNavigation *)navigation - withError:(NSError *)error { - [self onWebResourceError:error]; -} - -- (void)webView:(WKWebView *)webView - didFailProvisionalNavigation:(WKNavigation *)navigation - withError:(NSError *)error { - [self onWebResourceError:error]; -} - -- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView { - NSError *contentProcessTerminatedError = - [[NSError alloc] initWithDomain:WKErrorDomain - code:WKErrorWebContentProcessTerminated - userInfo:nil]; - [self onWebResourceError:contentProcessTerminatedError]; -} - -@end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h deleted file mode 100644 index 96af4ef6c578..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.h +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 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 -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FLTWKProgressionDelegate : NSObject - -- (instancetype)initWithWebView:(WKWebView *)webView channel:(FlutterMethodChannel *)channel; - -- (void)stopObservingProgress:(WKWebView *)webView; - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m deleted file mode 100644 index 8e7af4649aa0..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWKProgressionDelegate.m +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2013 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 "FLTWKProgressionDelegate.h" - -NSString *const FLTWKEstimatedProgressKeyPath = @"estimatedProgress"; - -@implementation FLTWKProgressionDelegate { - FlutterMethodChannel *_methodChannel; -} - -- (instancetype)initWithWebView:(WKWebView *)webView channel:(FlutterMethodChannel *)channel { - self = [super init]; - if (self) { - _methodChannel = channel; - [webView addObserver:self - forKeyPath:FLTWKEstimatedProgressKeyPath - options:NSKeyValueObservingOptionNew - context:nil]; - } - return self; -} - -- (void)stopObservingProgress:(WKWebView *)webView { - [webView removeObserver:self forKeyPath:FLTWKEstimatedProgressKeyPath]; -} - -- (void)observeValueForKeyPath:(NSString *)keyPath - ofObject:(id)object - change:(NSDictionary *)change - context:(void *)context { - if ([keyPath isEqualToString:FLTWKEstimatedProgressKeyPath]) { - NSNumber *newValue = - change[NSKeyValueChangeNewKey] ?: 0; // newValue is anywhere between 0.0 and 1.0 - int newValueAsInt = [newValue floatValue] * 100; // Anywhere between 0 and 100 - [_methodChannel invokeMethod:@"onProgress" arguments:@{@"progress" : @(newValueAsInt)}]; - } -} - -@end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h deleted file mode 100644 index 2a80c7d886f2..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.h +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2013 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 - -@interface FLTWebViewFlutterPlugin : NSObject -@end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m b/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m deleted file mode 100644 index 9f01416acc6a..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 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 "FLTWebViewFlutterPlugin.h" -#import "FLTCookieManager.h" -#import "FlutterWebView.h" - -@implementation FLTWebViewFlutterPlugin - -+ (void)registerWithRegistrar:(NSObject*)registrar { - FLTWebViewFactory* webviewFactory = - [[FLTWebViewFactory alloc] initWithMessenger:registrar.messenger]; - [registrar registerViewFactory:webviewFactory withId:@"plugins.flutter.io/webview"]; - [FLTCookieManager registerWithRegistrar:registrar]; -} - -@end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.h b/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.h deleted file mode 100644 index 6e795f7d1528..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.h +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2013 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 -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FLTWebViewController : NSObject - -- (instancetype)initWithFrame:(CGRect)frame - viewIdentifier:(int64_t)viewId - arguments:(id _Nullable)args - binaryMessenger:(NSObject*)messenger; - -- (UIView*)view; -@end - -@interface FLTWebViewFactory : NSObject -- (instancetype)initWithMessenger:(NSObject*)messenger; -@end - -/** - * The WkWebView used for the plugin. - * - * This class overrides some methods in `WKWebView` to serve the needs for the plugin. - */ -@interface FLTWKWebView : WKWebView -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m deleted file mode 100644 index 1604f2756f31..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/FlutterWebView.m +++ /dev/null @@ -1,475 +0,0 @@ -// Copyright 2013 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 "FlutterWebView.h" -#import "FLTWKNavigationDelegate.h" -#import "FLTWKProgressionDelegate.h" -#import "JavaScriptChannelHandler.h" - -@implementation FLTWebViewFactory { - NSObject* _messenger; -} - -- (instancetype)initWithMessenger:(NSObject*)messenger { - self = [super init]; - if (self) { - _messenger = messenger; - } - return self; -} - -- (NSObject*)createArgsCodec { - return [FlutterStandardMessageCodec sharedInstance]; -} - -- (NSObject*)createWithFrame:(CGRect)frame - viewIdentifier:(int64_t)viewId - arguments:(id _Nullable)args { - FLTWebViewController* webviewController = [[FLTWebViewController alloc] initWithFrame:frame - viewIdentifier:viewId - arguments:args - binaryMessenger:_messenger]; - return webviewController; -} - -@end - -@implementation FLTWKWebView - -- (void)setFrame:(CGRect)frame { - [super setFrame:frame]; - self.scrollView.contentInset = UIEdgeInsetsZero; - // We don't want the contentInsets to be adjusted by iOS, flutter should always take control of - // webview's contentInsets. - // self.scrollView.contentInset = UIEdgeInsetsZero; - if (@available(iOS 11, *)) { - // Above iOS 11, adjust contentInset to compensate the adjustedContentInset so the sum will - // always be 0. - if (UIEdgeInsetsEqualToEdgeInsets(self.scrollView.adjustedContentInset, UIEdgeInsetsZero)) { - return; - } - UIEdgeInsets insetToAdjust = self.scrollView.adjustedContentInset; - self.scrollView.contentInset = UIEdgeInsetsMake(-insetToAdjust.top, -insetToAdjust.left, - -insetToAdjust.bottom, -insetToAdjust.right); - } -} - -@end - -@implementation FLTWebViewController { - FLTWKWebView* _webView; - int64_t _viewId; - FlutterMethodChannel* _channel; - NSString* _currentUrl; - // The set of registered JavaScript channel names. - NSMutableSet* _javaScriptChannelNames; - FLTWKNavigationDelegate* _navigationDelegate; - FLTWKProgressionDelegate* _progressionDelegate; -} - -- (instancetype)initWithFrame:(CGRect)frame - viewIdentifier:(int64_t)viewId - arguments:(id _Nullable)args - binaryMessenger:(NSObject*)messenger { - if (self = [super init]) { - _viewId = viewId; - - NSString* channelName = [NSString stringWithFormat:@"plugins.flutter.io/webview_%lld", viewId]; - _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger]; - _javaScriptChannelNames = [[NSMutableSet alloc] init]; - - WKUserContentController* userContentController = [[WKUserContentController alloc] init]; - if ([args[@"javascriptChannelNames"] isKindOfClass:[NSArray class]]) { - NSArray* javaScriptChannelNames = args[@"javascriptChannelNames"]; - [_javaScriptChannelNames addObjectsFromArray:javaScriptChannelNames]; - [self registerJavaScriptChannels:_javaScriptChannelNames controller:userContentController]; - } - - NSDictionary* settings = args[@"settings"]; - - WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init]; - [self applyConfigurationSettings:settings toConfiguration:configuration]; - configuration.userContentController = userContentController; - [self updateAutoMediaPlaybackPolicy:args[@"autoMediaPlaybackPolicy"] - inConfiguration:configuration]; - - _webView = [[FLTWKWebView alloc] initWithFrame:frame configuration:configuration]; - _navigationDelegate = [[FLTWKNavigationDelegate alloc] initWithChannel:_channel]; - _webView.UIDelegate = self; - _webView.navigationDelegate = _navigationDelegate; - __weak __typeof__(self) weakSelf = self; - [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { - [weakSelf onMethodCall:call result:result]; - }]; - - if (@available(iOS 11.0, *)) { - _webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; - if (@available(iOS 13.0, *)) { - _webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = NO; - } - } - - [self applySettings:settings]; - // TODO(amirh): return an error if apply settings failed once it's possible to do so. - // https://github.com/flutter/flutter/issues/36228 - - NSString* initialUrl = args[@"initialUrl"]; - if ([initialUrl isKindOfClass:[NSString class]]) { - [self loadUrl:initialUrl]; - } - } - return self; -} - -- (void)dealloc { - if (_progressionDelegate != nil) { - [_progressionDelegate stopObservingProgress:_webView]; - } -} - -- (UIView*)view { - return _webView; -} - -- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { - if ([[call method] isEqualToString:@"updateSettings"]) { - [self onUpdateSettings:call result:result]; - } else if ([[call method] isEqualToString:@"loadUrl"]) { - [self onLoadUrl:call result:result]; - } else if ([[call method] isEqualToString:@"canGoBack"]) { - [self onCanGoBack:call result:result]; - } else if ([[call method] isEqualToString:@"canGoForward"]) { - [self onCanGoForward:call result:result]; - } else if ([[call method] isEqualToString:@"goBack"]) { - [self onGoBack:call result:result]; - } else if ([[call method] isEqualToString:@"goForward"]) { - [self onGoForward:call result:result]; - } else if ([[call method] isEqualToString:@"reload"]) { - [self onReload:call result:result]; - } else if ([[call method] isEqualToString:@"currentUrl"]) { - [self onCurrentUrl:call result:result]; - } else if ([[call method] isEqualToString:@"evaluateJavascript"]) { - [self onEvaluateJavaScript:call result:result]; - } else if ([[call method] isEqualToString:@"addJavascriptChannels"]) { - [self onAddJavaScriptChannels:call result:result]; - } else if ([[call method] isEqualToString:@"removeJavascriptChannels"]) { - [self onRemoveJavaScriptChannels:call result:result]; - } else if ([[call method] isEqualToString:@"clearCache"]) { - [self clearCache:result]; - } else if ([[call method] isEqualToString:@"getTitle"]) { - [self onGetTitle:result]; - } else if ([[call method] isEqualToString:@"scrollTo"]) { - [self onScrollTo:call result:result]; - } else if ([[call method] isEqualToString:@"scrollBy"]) { - [self onScrollBy:call result:result]; - } else if ([[call method] isEqualToString:@"getScrollX"]) { - [self getScrollX:call result:result]; - } else if ([[call method] isEqualToString:@"getScrollY"]) { - [self getScrollY:call result:result]; - } else { - result(FlutterMethodNotImplemented); - } -} - -- (void)onUpdateSettings:(FlutterMethodCall*)call result:(FlutterResult)result { - NSString* error = [self applySettings:[call arguments]]; - if (error == nil) { - result(nil); - return; - } - result([FlutterError errorWithCode:@"updateSettings_failed" message:error details:nil]); -} - -- (void)onLoadUrl:(FlutterMethodCall*)call result:(FlutterResult)result { - if (![self loadRequest:[call arguments]]) { - result([FlutterError - errorWithCode:@"loadUrl_failed" - message:@"Failed parsing the URL" - details:[NSString stringWithFormat:@"Request was: '%@'", [call arguments]]]); - } else { - result(nil); - } -} - -- (void)onCanGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { - BOOL canGoBack = [_webView canGoBack]; - result(@(canGoBack)); -} - -- (void)onCanGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { - BOOL canGoForward = [_webView canGoForward]; - result(@(canGoForward)); -} - -- (void)onGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { - [_webView goBack]; - result(nil); -} - -- (void)onGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { - [_webView goForward]; - result(nil); -} - -- (void)onReload:(FlutterMethodCall*)call result:(FlutterResult)result { - [_webView reload]; - result(nil); -} - -- (void)onCurrentUrl:(FlutterMethodCall*)call result:(FlutterResult)result { - _currentUrl = [[_webView URL] absoluteString]; - result(_currentUrl); -} - -- (void)onEvaluateJavaScript:(FlutterMethodCall*)call result:(FlutterResult)result { - NSString* jsString = [call arguments]; - if (!jsString) { - result([FlutterError errorWithCode:@"evaluateJavaScript_failed" - message:@"JavaScript String cannot be null" - details:nil]); - return; - } - [_webView evaluateJavaScript:jsString - completionHandler:^(_Nullable id evaluateResult, NSError* _Nullable error) { - if (error) { - result([FlutterError - errorWithCode:@"evaluateJavaScript_failed" - message:@"Failed evaluating JavaScript" - details:[NSString stringWithFormat:@"JavaScript string was: '%@'\n%@", - jsString, error]]); - } else { - result([NSString stringWithFormat:@"%@", evaluateResult]); - } - }]; -} - -- (void)onAddJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { - NSArray* channelNames = [call arguments]; - NSSet* channelNamesSet = [[NSSet alloc] initWithArray:channelNames]; - [_javaScriptChannelNames addObjectsFromArray:channelNames]; - [self registerJavaScriptChannels:channelNamesSet - controller:_webView.configuration.userContentController]; - result(nil); -} - -- (void)onRemoveJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { - // WkWebView does not support removing a single user script, so instead we remove all - // user scripts, all message handlers. And re-register channels that shouldn't be removed. - [_webView.configuration.userContentController removeAllUserScripts]; - for (NSString* channelName in _javaScriptChannelNames) { - [_webView.configuration.userContentController removeScriptMessageHandlerForName:channelName]; - } - - NSArray* channelNamesToRemove = [call arguments]; - for (NSString* channelName in channelNamesToRemove) { - [_javaScriptChannelNames removeObject:channelName]; - } - - [self registerJavaScriptChannels:_javaScriptChannelNames - controller:_webView.configuration.userContentController]; - result(nil); -} - -- (void)clearCache:(FlutterResult)result { - NSSet* cacheDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; - WKWebsiteDataStore* dataStore = [WKWebsiteDataStore defaultDataStore]; - NSDate* dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; - [dataStore removeDataOfTypes:cacheDataTypes - modifiedSince:dateFrom - completionHandler:^{ - result(nil); - }]; -} - -- (void)onGetTitle:(FlutterResult)result { - NSString* title = _webView.title; - result(title); -} - -- (void)onScrollTo:(FlutterMethodCall*)call result:(FlutterResult)result { - NSDictionary* arguments = [call arguments]; - int x = [arguments[@"x"] intValue]; - int y = [arguments[@"y"] intValue]; - - _webView.scrollView.contentOffset = CGPointMake(x, y); - result(nil); -} - -- (void)onScrollBy:(FlutterMethodCall*)call result:(FlutterResult)result { - CGPoint contentOffset = _webView.scrollView.contentOffset; - - NSDictionary* arguments = [call arguments]; - int x = [arguments[@"x"] intValue] + contentOffset.x; - int y = [arguments[@"y"] intValue] + contentOffset.y; - - _webView.scrollView.contentOffset = CGPointMake(x, y); - result(nil); -} - -- (void)getScrollX:(FlutterMethodCall*)call result:(FlutterResult)result { - int offsetX = _webView.scrollView.contentOffset.x; - result(@(offsetX)); -} - -- (void)getScrollY:(FlutterMethodCall*)call result:(FlutterResult)result { - int offsetY = _webView.scrollView.contentOffset.y; - result(@(offsetY)); -} - -// Returns nil when successful, or an error message when one or more keys are unknown. -- (NSString*)applySettings:(NSDictionary*)settings { - NSMutableArray* unknownKeys = [[NSMutableArray alloc] init]; - for (NSString* key in settings) { - if ([key isEqualToString:@"jsMode"]) { - NSNumber* mode = settings[key]; - [self updateJsMode:mode]; - } else if ([key isEqualToString:@"hasNavigationDelegate"]) { - NSNumber* hasDartNavigationDelegate = settings[key]; - _navigationDelegate.hasDartNavigationDelegate = [hasDartNavigationDelegate boolValue]; - } else if ([key isEqualToString:@"hasProgressTracking"]) { - NSNumber* hasProgressTrackingValue = settings[key]; - bool hasProgressTracking = [hasProgressTrackingValue boolValue]; - if (hasProgressTracking) { - _progressionDelegate = [[FLTWKProgressionDelegate alloc] initWithWebView:_webView - channel:_channel]; - } - } else if ([key isEqualToString:@"debuggingEnabled"]) { - // no-op debugging is always enabled on iOS. - } else if ([key isEqualToString:@"gestureNavigationEnabled"]) { - NSNumber* allowsBackForwardNavigationGestures = settings[key]; - _webView.allowsBackForwardNavigationGestures = - [allowsBackForwardNavigationGestures boolValue]; - } else if ([key isEqualToString:@"userAgent"]) { - NSString* userAgent = settings[key]; - [self updateUserAgent:[userAgent isEqual:[NSNull null]] ? nil : userAgent]; - } else { - [unknownKeys addObject:key]; - } - } - if ([unknownKeys count] == 0) { - return nil; - } - return [NSString stringWithFormat:@"webview_flutter: unknown setting keys: {%@}", - [unknownKeys componentsJoinedByString:@", "]]; -} - -- (void)applyConfigurationSettings:(NSDictionary*)settings - toConfiguration:(WKWebViewConfiguration*)configuration { - NSAssert(configuration != _webView.configuration, - @"configuration needs to be updated before webView.configuration."); - for (NSString* key in settings) { - if ([key isEqualToString:@"allowsInlineMediaPlayback"]) { - NSNumber* allowsInlineMediaPlayback = settings[key]; - configuration.allowsInlineMediaPlayback = [allowsInlineMediaPlayback boolValue]; - } - } -} - -- (void)updateJsMode:(NSNumber*)mode { - WKPreferences* preferences = [[_webView configuration] preferences]; - switch ([mode integerValue]) { - case 0: // disabled - [preferences setJavaScriptEnabled:NO]; - break; - case 1: // unrestricted - [preferences setJavaScriptEnabled:YES]; - break; - default: - NSLog(@"webview_flutter: unknown JavaScript mode: %@", mode); - } -} - -- (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy - inConfiguration:(WKWebViewConfiguration*)configuration { - switch ([policy integerValue]) { - case 0: // require_user_action_for_all_media_types - if (@available(iOS 10.0, *)) { - configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; - } else { - configuration.requiresUserActionForMediaPlayback = true; - } - break; - case 1: // always_allow - if (@available(iOS 10.0, *)) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; -#pragma clang diagnostic pop - } else { - configuration.requiresUserActionForMediaPlayback = false; - } - break; - default: - NSLog(@"webview_flutter: unknown auto media playback policy: %@", policy); - } -} - -- (bool)loadRequest:(NSDictionary*)request { - if (!request) { - return false; - } - - NSString* url = request[@"url"]; - if ([url isKindOfClass:[NSString class]]) { - id headers = request[@"headers"]; - if ([headers isKindOfClass:[NSDictionary class]]) { - return [self loadUrl:url withHeaders:headers]; - } else { - return [self loadUrl:url]; - } - } - - return false; -} - -- (bool)loadUrl:(NSString*)url { - return [self loadUrl:url withHeaders:[NSMutableDictionary dictionary]]; -} - -- (bool)loadUrl:(NSString*)url withHeaders:(NSDictionary*)headers { - NSURL* nsUrl = [NSURL URLWithString:url]; - if (!nsUrl) { - return false; - } - NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:nsUrl]; - [request setAllHTTPHeaderFields:headers]; - [_webView loadRequest:request]; - return true; -} - -- (void)registerJavaScriptChannels:(NSSet*)channelNames - controller:(WKUserContentController*)userContentController { - for (NSString* channelName in channelNames) { - FLTJavaScriptChannel* channel = - [[FLTJavaScriptChannel alloc] initWithMethodChannel:_channel - javaScriptChannelName:channelName]; - [userContentController addScriptMessageHandler:channel name:channelName]; - NSString* wrapperSource = [NSString - stringWithFormat:@"window.%@ = webkit.messageHandlers.%@;", channelName, channelName]; - WKUserScript* wrapperScript = - [[WKUserScript alloc] initWithSource:wrapperSource - injectionTime:WKUserScriptInjectionTimeAtDocumentStart - forMainFrameOnly:NO]; - [userContentController addUserScript:wrapperScript]; - } -} - -- (void)updateUserAgent:(NSString*)userAgent { - [_webView setCustomUserAgent:userAgent]; -} - -#pragma mark WKUIDelegate - -- (WKWebView*)webView:(WKWebView*)webView - createWebViewWithConfiguration:(WKWebViewConfiguration*)configuration - forNavigationAction:(WKNavigationAction*)navigationAction - windowFeatures:(WKWindowFeatures*)windowFeatures { - if (!navigationAction.targetFrame.isMainFrame) { - [webView loadRequest:navigationAction.request]; - } - - return nil; -} - -@end diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.h b/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.h deleted file mode 100644 index a0a5ec657295..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.h +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2013 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 -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FLTJavaScriptChannel : NSObject - -- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel - javaScriptChannelName:(NSString*)javaScriptChannelName; - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.m b/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.m deleted file mode 100644 index ec9a363a4b2e..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/Classes/JavaScriptChannelHandler.m +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2013 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 "JavaScriptChannelHandler.h" - -@implementation FLTJavaScriptChannel { - FlutterMethodChannel* _methodChannel; - NSString* _javaScriptChannelName; -} - -- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel - javaScriptChannelName:(NSString*)javaScriptChannelName { - self = [super init]; - NSAssert(methodChannel != nil, @"methodChannel must not be null."); - NSAssert(javaScriptChannelName != nil, @"javaScriptChannelName must not be null."); - if (self) { - _methodChannel = methodChannel; - _javaScriptChannelName = javaScriptChannelName; - } - return self; -} - -- (void)userContentController:(WKUserContentController*)userContentController - didReceiveScriptMessage:(WKScriptMessage*)message { - NSAssert(_methodChannel != nil, @"Can't send a message to an unitialized JavaScript channel."); - NSAssert(_javaScriptChannelName != nil, - @"Can't send a message to an unitialized JavaScript channel."); - NSDictionary* arguments = @{ - @"channel" : _javaScriptChannelName, - @"message" : [NSString stringWithFormat:@"%@", message.body] - }; - [_methodChannel invokeMethod:@"javascriptChannelMessage" arguments:arguments]; -} - -@end diff --git a/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec b/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec deleted file mode 100644 index 2e021994b8f4..000000000000 --- a/packages/webview_flutter/webview_flutter/ios/webview_flutter.podspec +++ /dev/null @@ -1,23 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html -# -Pod::Spec.new do |s| - s.name = 'webview_flutter' - s.version = '0.0.1' - s.summary = 'A WebView Plugin for Flutter.' - s.description = <<-DESC -A Flutter plugin that provides a WebView widget. -Downloaded by pub (not CocoaPods). - DESC - s.homepage = 'https://github.com/flutter/plugins' - s.license = { :type => 'BSD', :file => '../LICENSE' } - s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } - s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/webview_flutter' } - s.documentation_url = 'https://pub.dev/packages/webview_flutter' - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - s.dependency 'Flutter' - - s.platform = :ios, '9.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } -end diff --git a/packages/webview_flutter/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/webview_flutter/lib/platform_interface.dart index 92aa87b7480f..aa7b3a0931e8 100644 --- a/packages/webview_flutter/webview_flutter/lib/platform_interface.dart +++ b/packages/webview_flutter/webview_flutter/lib/platform_interface.dart @@ -2,547 +2,23 @@ // 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:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; - -import 'webview_flutter.dart'; - -/// Interface for callbacks made by [WebViewPlatformController]. -/// -/// The webview plugin implements this class, and passes an instance to the [WebViewPlatformController]. -/// [WebViewPlatformController] is notifying this handler on events that happened on the platform's webview. -abstract class WebViewPlatformCallbacksHandler { - /// Invoked by [WebViewPlatformController] when a JavaScript channel message is received. - void onJavaScriptChannelMessage(String channel, String message); - - /// Invoked by [WebViewPlatformController] when a navigation request is pending. - /// - /// If true is returned the navigation is allowed, otherwise it is blocked. - FutureOr onNavigationRequest( - {required String url, required bool isForMainFrame}); - - /// Invoked by [WebViewPlatformController] when a page has started loading. - void onPageStarted(String url); - - /// Invoked by [WebViewPlatformController] when a page has finished loading. - void onPageFinished(String url); - - /// Invoked by [WebViewPlatformController] when a page is loading. - /// /// Only works when [WebSettings.hasProgressTracking] is set to `true`. - void onProgress(int progress); - - /// Report web resource loading error to the host application. - void onWebResourceError(WebResourceError error); -} - -/// Possible error type categorizations used by [WebResourceError]. -enum WebResourceErrorType { - /// User authentication failed on server. - authentication, - - /// Malformed URL. - badUrl, - - /// Failed to connect to the server. - connect, - - /// Failed to perform SSL handshake. - failedSslHandshake, - - /// Generic file error. - file, - - /// File not found. - fileNotFound, - - /// Server or proxy hostname lookup failed. - hostLookup, - - /// Failed to read or write to the server. - io, - - /// User authentication failed on proxy. - proxyAuthentication, - - /// Too many redirects. - redirectLoop, - - /// Connection timed out. - timeout, - - /// Too many requests during this load. - tooManyRequests, - - /// Generic error. - unknown, - - /// Resource load was canceled by Safe Browsing. - unsafeResource, - - /// Unsupported authentication scheme (not basic or digest). - unsupportedAuthScheme, - - /// Unsupported URI scheme. - unsupportedScheme, - - /// The web content process was terminated. - webContentProcessTerminated, - - /// The web view was invalidated. - webViewInvalidated, - - /// A JavaScript exception occurred. - javaScriptExceptionOccurred, - - /// The result of JavaScript execution could not be returned. - javaScriptResultTypeIsUnsupported, -} - -/// Error returned in `WebView.onWebResourceError` when a web resource loading error has occurred. -class WebResourceError { - /// Creates a new [WebResourceError] - /// - /// A user should not need to instantiate this class, but will receive one in - /// [WebResourceErrorCallback]. - WebResourceError({ - required this.errorCode, - required this.description, - this.domain, - this.errorType, - this.failingUrl, - }) : assert(errorCode != null), - assert(description != null); - - /// Raw code of the error from the respective platform. - /// - /// On Android, the error code will be a constant from a - /// [WebViewClient](https://developer.android.com/reference/android/webkit/WebViewClient#summary) and - /// will have a corresponding [errorType]. - /// - /// On iOS, the error code will be a constant from `NSError.code` in - /// Objective-C. See - /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html - /// for more information on error handling on iOS. Some possible error codes - /// can be found at https://developer.apple.com/documentation/webkit/wkerrorcode?language=objc. - final int errorCode; - - /// The domain of where to find the error code. - /// - /// This field is only available on iOS and represents a "domain" from where - /// the [errorCode] is from. This value is taken directly from an `NSError` - /// in Objective-C. See - /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html - /// for more information on error handling on iOS. - final String? domain; - - /// Description of the error that can be used to communicate the problem to the user. - final String description; - - /// The type this error can be categorized as. - /// - /// This will never be `null` on Android, but can be `null` on iOS. - final WebResourceErrorType? errorType; - - /// Gets the URL for which the resource request was made. - /// - /// This value is not provided on iOS. Alternatively, you can keep track of - /// the last values provided to [WebViewPlatformController.loadUrl]. - final String? failingUrl; -} - -/// Interface for talking to the webview's platform implementation. -/// -/// An instance implementing this interface is passed to the `onWebViewPlatformCreated` callback that is -/// passed to [WebViewPlatformBuilder#onWebViewPlatformCreated]. -/// -/// Platform implementations that live in a separate package should extend this class rather than -/// implement it as webview_flutter does not consider newly added methods to be breaking changes. -/// Extending this class (using `extends`) ensures that the subclass will get the default -/// implementation, while platform implementations that `implements` this interface will be broken -/// by newly added [WebViewPlatformController] methods. -abstract class WebViewPlatformController { - /// Creates a new WebViewPlatform. - /// - /// Callbacks made by the WebView will be delegated to `handler`. - /// - /// The `handler` parameter must not be null. - WebViewPlatformController(WebViewPlatformCallbacksHandler handler); - - /// Loads the specified URL. - /// - /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will - /// be added as key value pairs of HTTP headers for the request. - /// - /// `url` must not be null. - /// - /// Throws an ArgumentError if `url` is not a valid URL string. - Future loadUrl( - String url, - Map? headers, - ) { - throw UnimplementedError( - "WebView loadUrl is not implemented on the current platform"); - } - - /// Updates the webview settings. - /// - /// Any non null field in `settings` will be set as the new setting value. - /// All null fields in `settings` are ignored. - Future updateSettings(WebSettings setting) { - throw UnimplementedError( - "WebView updateSettings is not implemented on the current platform"); - } - - /// Accessor to the current URL that the WebView is displaying. - /// - /// If no URL was ever loaded, returns `null`. - Future currentUrl() { - throw UnimplementedError( - "WebView currentUrl is not implemented on the current platform"); - } - - /// Checks whether there's a back history item. - Future canGoBack() { - throw UnimplementedError( - "WebView canGoBack is not implemented on the current platform"); - } - - /// Checks whether there's a forward history item. - Future canGoForward() { - throw UnimplementedError( - "WebView canGoForward is not implemented on the current platform"); - } - - /// Goes back in the history of this WebView. - /// - /// If there is no back history item this is a no-op. - Future goBack() { - throw UnimplementedError( - "WebView goBack is not implemented on the current platform"); - } - - /// Goes forward in the history of this WebView. - /// - /// If there is no forward history item this is a no-op. - Future goForward() { - throw UnimplementedError( - "WebView goForward is not implemented on the current platform"); - } - - /// Reloads the current URL. - Future reload() { - throw UnimplementedError( - "WebView reload is not implemented on the current platform"); - } - - /// Clears all caches used by the [WebView]. - /// - /// The following caches are cleared: - /// 1. Browser HTTP Cache. - /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. - /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. - /// 3. Application cache. - /// 4. Local Storage. - Future clearCache() { - throw UnimplementedError( - "WebView clearCache is not implemented on the current platform"); - } - - /// Evaluates a JavaScript expression in the context of the current page. - /// - /// The Future completes with an error if a JavaScript error occurred, or if the type of the - /// evaluated expression is not supported(e.g on iOS not all non primitive type can be evaluated). - Future evaluateJavascript(String javascriptString) { - throw UnimplementedError( - "WebView evaluateJavascript is not implemented on the current platform"); - } - - /// Adds new JavaScript channels to the set of enabled channels. - /// - /// For each value in this list the platform's webview should make sure that a corresponding - /// property with a postMessage method is set on `window`. For example for a JavaScript channel - /// named `Foo` it should be possible for JavaScript code executing in the webview to do - /// - /// ```javascript - /// Foo.postMessage('hello'); - /// ``` - /// - /// See also: [CreationParams.javascriptChannelNames]. - Future addJavascriptChannels(Set javascriptChannelNames) { - throw UnimplementedError( - "WebView addJavascriptChannels is not implemented on the current platform"); - } - - /// Removes JavaScript channel names from the set of enabled channels. - /// - /// This disables channels that were previously enabled by [addJavaScriptChannels] or through - /// [CreationParams.javascriptChannelNames]. - Future removeJavascriptChannels(Set javascriptChannelNames) { - throw UnimplementedError( - "WebView removeJavascriptChannels is not implemented on the current platform"); - } - - /// Returns the title of the currently loaded page. - Future getTitle() { - throw UnimplementedError( - "WebView getTitle is not implemented on the current platform"); - } - - /// Set the scrolled position of this view. - /// - /// The parameters `x` and `y` specify the position to scroll to in WebView pixels. - Future scrollTo(int x, int y) { - throw UnimplementedError( - "WebView scrollTo is not implemented on the current platform"); - } - - /// Move the scrolled position of this view. - /// - /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by. - Future scrollBy(int x, int y) { - throw UnimplementedError( - "WebView scrollBy is not implemented on the current platform"); - } - - /// Return the horizontal scroll position of this view. - /// - /// Scroll position is measured from left. - Future getScrollX() { - throw UnimplementedError( - "WebView getScrollX is not implemented on the current platform"); - } - - /// Return the vertical scroll position of this view. - /// - /// Scroll position is measured from top. - Future getScrollY() { - throw UnimplementedError( - "WebView getScrollY is not implemented on the current platform"); - } -} - -/// A single setting for configuring a WebViewPlatform which may be absent. -class WebSetting { - /// Constructs an absent setting instance. - /// - /// The [isPresent] field for the instance will be false. - /// - /// Accessing [value] for an absent instance will throw. - WebSetting.absent() - : _value = null, - isPresent = false; - - /// Constructs a setting of the given `value`. - /// - /// The [isPresent] field for the instance will be true. - WebSetting.of(T value) - : _value = value, - isPresent = true; - - final T? _value; - - /// The setting's value. - /// - /// Throws if [WebSetting.isPresent] is false. - T get value { - if (!isPresent) { - throw StateError('Cannot access a value of an absent WebSetting'); - } - assert(isPresent); - // The intention of this getter is to return T whether it is nullable or - // not whereas _value is of type T? since _value can be null even when - // T is not nullable (when isPresent == false). - // - // We promote _value to T using `as T` instead of `!` operator to handle - // the case when _value is legitimately null (and T is a nullable type). - // `!` operator would always throw if _value is null. - return _value as T; - } - - /// True when this web setting instance contains a value. - /// - /// When false the [WebSetting.value] getter throws. - final bool isPresent; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - final WebSetting typedOther = other as WebSetting; - return typedOther.isPresent == isPresent && typedOther._value == _value; - } - - @override - int get hashCode => hashValues(_value, isPresent); -} - -/// Settings for configuring a WebViewPlatform. -/// -/// Initial settings are passed as part of [CreationParams], settings updates are sent with -/// [WebViewPlatform#updateSettings]. -/// -/// The `userAgent` parameter must not be null. -class WebSettings { - /// Construct an instance with initial settings. Future setting changes can be - /// sent with [WebviewPlatform#updateSettings]. - /// - /// The `userAgent` parameter must not be null. - WebSettings({ - this.javascriptMode, - this.hasNavigationDelegate, - this.hasProgressTracking, - this.debuggingEnabled, - this.gestureNavigationEnabled, - this.allowsInlineMediaPlayback, - required this.userAgent, - }) : assert(userAgent != null); - - /// The JavaScript execution mode to be used by the webview. - final JavascriptMode? javascriptMode; - - /// Whether the [WebView] has a [NavigationDelegate] set. - final bool? hasNavigationDelegate; - - /// Whether the [WebView] should track page loading progress. - /// See also: [WebViewPlatformCallbacksHandler.onProgress] to get the progress. - final bool? hasProgressTracking; - - /// Whether to enable the platform's webview content debugging tools. - /// - /// See also: [WebView.debuggingEnabled]. - final bool? debuggingEnabled; - - /// Whether to play HTML5 videos inline or use the native full-screen controller on iOS. - /// - /// This will have no effect on Android. - final bool? allowsInlineMediaPlayback; - - /// The value used for the HTTP `User-Agent:` request header. - /// - /// If [userAgent.value] is null the platform's default user agent should be used. - /// - /// An absent value ([userAgent.isPresent] is false) represents no change to this setting from the - /// last time it was set. - /// - /// See also [WebView.userAgent]. - final WebSetting userAgent; - - /// Whether to allow swipe based navigation in iOS. - /// - /// See also: [WebView.gestureNavigationEnabled] - final bool? gestureNavigationEnabled; - - @override - String toString() { - return 'WebSettings(javascriptMode: $javascriptMode, hasNavigationDelegate: $hasNavigationDelegate, hasProgressTracking: $hasProgressTracking, debuggingEnabled: $debuggingEnabled, gestureNavigationEnabled: $gestureNavigationEnabled, userAgent: $userAgent, allowsInlineMediaPlayback: $allowsInlineMediaPlayback)'; - } -} - -/// Configuration to use when creating a new [WebViewPlatformController]. -/// -/// The `autoMediaPlaybackPolicy` parameter must not be null. -class CreationParams { - /// Constructs an instance to use when creating a new - /// [WebViewPlatformController]. - /// - /// The `autoMediaPlaybackPolicy` parameter must not be null. - CreationParams({ - this.initialUrl, - this.webSettings, - this.javascriptChannelNames = const {}, - this.userAgent, - this.autoMediaPlaybackPolicy = - AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, - }) : assert(autoMediaPlaybackPolicy != null); - - /// The initialUrl to load in the webview. - /// - /// When null the webview will be created without loading any page. - final String? initialUrl; - - /// The initial [WebSettings] for the new webview. - /// - /// This can later be updated with [WebViewPlatformController.updateSettings]. - final WebSettings? webSettings; - - /// The initial set of JavaScript channels that are configured for this webview. - /// - /// For each value in this set the platform's webview should make sure that a corresponding - /// property with a postMessage method is set on `window`. For example for a JavaScript channel - /// named `Foo` it should be possible for JavaScript code executing in the webview to do - /// - /// ```javascript - /// Foo.postMessage('hello'); - /// ``` - // TODO(amirh): describe what should happen when postMessage is called once that code is migrated - // to PlatformWebView. - final Set javascriptChannelNames; - - /// The value used for the HTTP User-Agent: request header. - /// - /// When null the platform's webview default is used for the User-Agent header. - final String? userAgent; - - /// Which restrictions apply on automatic media playback. - final AutoMediaPlaybackPolicy autoMediaPlaybackPolicy; - - @override - String toString() { - return '$runtimeType(initialUrl: $initialUrl, settings: $webSettings, javascriptChannelNames: $javascriptChannelNames, UserAgent: $userAgent)'; - } -} - -/// Signature for callbacks reporting that a [WebViewPlatformController] was created. -/// -/// See also the `onWebViewPlatformCreated` argument for [WebViewPlatform.build]. -typedef WebViewPlatformCreatedCallback = void Function( - WebViewPlatformController? webViewPlatformController); - -/// Interface for a platform implementation of a WebView. -/// -/// [WebView.platform] controls the builder that is used by [WebView]. -/// [AndroidWebViewPlatform] and [CupertinoWebViewPlatform] are the default implementations -/// for Android and iOS respectively. -abstract class WebViewPlatform { - /// Builds a new WebView. - /// - /// Returns a Widget tree that embeds the created webview. - /// - /// `creationParams` are the initial parameters used to setup the webview. - /// - /// `webViewPlatformHandler` will be used for handling callbacks that are made by the created - /// [WebViewPlatformController]. - /// - /// `onWebViewPlatformCreated` will be invoked after the platform specific [WebViewPlatformController] - /// implementation is created with the [WebViewPlatformController] instance as a parameter. - /// - /// `gestureRecognizers` specifies which gestures should be consumed by the web view. - /// It is possible for other gesture recognizers to be competing with the web view on pointer - /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle - /// vertical drags. The web view will claim gestures that are recognized by any of the - /// recognizers on this list. - /// When `gestureRecognizers` is empty or null, the web view will only handle pointer events for gestures that - /// were not claimed by any other gesture recognizer. - /// - /// `webViewPlatformHandler` must not be null. - Widget build({ - required BuildContext context, - // TODO(amirh): convert this to be the actual parameters. - // I'm starting without it as the PR is starting to become pretty big. - // I'll followup with the conversion PR. - required CreationParams creationParams, - required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, - WebViewPlatformCreatedCallback? onWebViewPlatformCreated, - Set>? gestureRecognizers, - }); - - /// Clears all cookies for all [WebView] instances. - /// - /// Returns true if cookies were present before clearing, else false. - Future clearCookies() { - throw UnimplementedError( - "WebView clearCookies is not implemented on the current platform"); - } -} +/// Re-export the classes from the webview_flutter_platform_interface through +/// the `platform_interface.dart` file so we don't accidentally break any +/// non-endorsed existing implementations of the interface. +export 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart' + show + AutoMediaPlaybackPolicy, + CreationParams, + JavascriptChannel, + JavascriptChannelRegistry, + JavascriptMessage, + JavascriptMode, + JavascriptMessageHandler, + WebViewPlatform, + WebViewPlatformCallbacksHandler, + WebViewPlatformController, + WebViewPlatformCreatedCallback, + WebSetting, + WebSettings, + WebResourceError, + WebResourceErrorType; diff --git a/packages/webview_flutter/webview_flutter/lib/src/webview.dart b/packages/webview_flutter/webview_flutter/lib/src/webview.dart new file mode 100644 index 000000000000..7699cc46c5d3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/lib/src/webview.dart @@ -0,0 +1,681 @@ +// Copyright 2013 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:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_android/webview_android.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +import '../platform_interface.dart'; + +/// Optional callback invoked when a web view is first created. [controller] is +/// the [WebViewController] for the created web view. +typedef void WebViewCreatedCallback(WebViewController controller); + +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({required this.url, required this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef FutureOr NavigationDelegate( + NavigationRequest navigation); + +/// Signature for when a [WebView] has started loading a page. +typedef void PageStartedCallback(String url); + +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + +/// Signature for when a [WebView] is loading a page. +typedef void PageLoadingCallback(int progress); + +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + +/// A web view widget for showing html content. +/// +/// There is a known issue that on iOS 13.4 and 13.5, other flutter widgets covering +/// the `WebView` is not able to block the `WebView` from receiving touch events. +/// See https://github.com/flutter/flutter/issues/53490. +class WebView extends StatefulWidget { + /// Creates a new web view. + /// + /// The web view can be controlled using a `WebViewController` that is passed to the + /// `onWebViewCreated` callback once the web view is created. + /// + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. + const WebView({ + Key? key, + this.onWebViewCreated, + this.initialUrl, + this.javascriptMode = JavascriptMode.disabled, + this.javascriptChannels, + this.navigationDelegate, + this.gestureRecognizers, + this.onPageStarted, + this.onPageFinished, + this.onProgress, + this.onWebResourceError, + this.debuggingEnabled = false, + this.gestureNavigationEnabled = false, + this.userAgent, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.allowsInlineMediaPlayback = false, + }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), + assert(allowsInlineMediaPlayback != null), + super(key: key); + + static WebViewPlatform? _platform; + + /// Sets a custom [WebViewPlatform]. + /// + /// This property can be set to use a custom platform implementation for WebViews. + /// + /// Setting `platform` doesn't affect [WebView]s that were already created. + /// + /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. + static set platform(WebViewPlatform? platform) { + _platform = platform; + } + + /// The WebView platform that's used by this WebView. + /// + /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. + static WebViewPlatform get platform { + if (_platform == null) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + _platform = AndroidWebView(); + break; + case TargetPlatform.iOS: + _platform = CupertinoWebView(); + break; + default: + throw UnsupportedError( + "Trying to use the default webview implementation for $defaultTargetPlatform but there isn't a default one"); + } + } + return _platform!; + } + + /// If not null invoked once the web view is created. + final WebViewCreatedCallback? onWebViewCreated; + + /// Which gestures should be consumed by the web view. + /// + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + /// The initial URL to load. + final String? initialUrl; + + /// Whether Javascript execution is enabled. + final JavascriptMode javascriptMode; + + /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. + /// + /// For each [JavascriptChannel] in the set, a channel object is made available for the + /// JavaScript code in a window property named [JavascriptChannel.name]. + /// The JavaScript code can then call `postMessage` on that object to send a message that will be + /// passed to [JavascriptChannel.onMessageReceived]. + /// + /// For example for the following JavascriptChannel: + /// + /// ```dart + /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); + /// ``` + /// + /// JavaScript code can call: + /// + /// ```javascript + /// Print.postMessage('Hello'); + /// ``` + /// + /// To asynchronously invoke the message handler which will print the message to standard output. + /// + /// Adding a new JavaScript channel only takes affect after the next page is loaded. + /// + /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple + /// channels in the list. + /// + /// A null value is equivalent to an empty set. + final Set? javascriptChannels; + + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate? navigationDelegate; + + /// Controls whether inline playback of HTML5 videos is allowed on iOS. + /// + /// This field is ignored on Android because Android allows it by default. + /// + /// By default `allowsInlineMediaPlayback` is false. + final bool allowsInlineMediaPlayback; + + /// Invoked when a page starts loading. + final PageStartedCallback? onPageStarted; + + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any Javascript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.evaluateJavascript] can assume this. + final PageFinishedCallback? onPageFinished; + + /// Invoked when a page is loading. + final PageLoadingCallback? onProgress; + + /// Invoked when a web resource has failed to load. + /// + /// This callback is only called for the main page. + final WebResourceErrorCallback? onWebResourceError; + + /// Controls whether WebView debugging is enabled. + /// + /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). + /// + /// WebView debugging is enabled by default in dev builds on iOS. + /// + /// To debug WebViews on iOS: + /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) + /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> + /// + /// By default `debuggingEnabled` is false. + final bool debuggingEnabled; + + /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. + /// + /// This only works on iOS. + /// + /// By default `gestureNavigationEnabled` is false. + final bool gestureNavigationEnabled; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String? userAgent; + + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + + @override + State createState() => _WebViewState(); +} + +class _WebViewState extends State { + final Completer _controller = + Completer(); + + late JavascriptChannelRegistry _javascriptChannelRegistry; + late _PlatformCallbacksHandler _platformCallbacksHandler; + + @override + Widget build(BuildContext context) { + return WebView.platform.build( + context: context, + onWebViewPlatformCreated: _onWebViewPlatformCreated, + webViewPlatformCallbacksHandler: _platformCallbacksHandler, + javascriptChannelRegistry: _javascriptChannelRegistry, + gestureRecognizers: widget.gestureRecognizers, + creationParams: _creationParamsfromWidget(widget), + ); + } + + @override + void initState() { + super.initState(); + _assertJavascriptChannelNamesAreUnique(); + _platformCallbacksHandler = _PlatformCallbacksHandler(widget); + _javascriptChannelRegistry = + JavascriptChannelRegistry(widget.javascriptChannels); + } + + @override + void didUpdateWidget(WebView oldWidget) { + super.didUpdateWidget(oldWidget); + _assertJavascriptChannelNamesAreUnique(); + _controller.future.then((WebViewController controller) { + _platformCallbacksHandler._widget = widget; + controller._updateWidget(widget); + }); + } + + void _onWebViewPlatformCreated(WebViewPlatformController? webViewPlatform) { + final WebViewController controller = WebViewController._( + widget, + webViewPlatform!, + _javascriptChannelRegistry, + ); + _controller.complete(controller); + if (widget.onWebViewCreated != null) { + widget.onWebViewCreated!(controller); + } + } + + void _assertJavascriptChannelNamesAreUnique() { + if (widget.javascriptChannels == null || + widget.javascriptChannels!.isEmpty) { + return; + } + assert(_extractChannelNames(widget.javascriptChannels).length == + widget.javascriptChannels!.length); + } +} + +CreationParams _creationParamsfromWidget(WebView widget) { + return CreationParams( + initialUrl: widget.initialUrl, + webSettings: _webSettingsFromWidget(widget), + javascriptChannelNames: _extractChannelNames(widget.javascriptChannels), + userAgent: widget.userAgent, + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, + ); +} + +WebSettings _webSettingsFromWidget(WebView widget) { + return WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + hasProgressTracking: widget.onProgress != null, + debuggingEnabled: widget.debuggingEnabled, + gestureNavigationEnabled: widget.gestureNavigationEnabled, + allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, + userAgent: WebSetting.of(widget.userAgent), + ); +} + +// This method assumes that no fields in `currentValue` are null. +WebSettings _clearUnchangedWebSettings( + WebSettings currentValue, WebSettings newValue) { + assert(currentValue.javascriptMode != null); + assert(currentValue.hasNavigationDelegate != null); + assert(currentValue.hasProgressTracking != null); + assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent != null); + assert(newValue.javascriptMode != null); + assert(newValue.hasNavigationDelegate != null); + assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent != null); + + JavascriptMode? javascriptMode; + bool? hasNavigationDelegate; + bool? hasProgressTracking; + bool? debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); + if (currentValue.javascriptMode != newValue.javascriptMode) { + javascriptMode = newValue.javascriptMode; + } + if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { + hasNavigationDelegate = newValue.hasNavigationDelegate; + } + if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { + hasProgressTracking = newValue.hasProgressTracking; + } + if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { + debuggingEnabled = newValue.debuggingEnabled; + } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } + + return WebSettings( + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + hasProgressTracking: hasProgressTracking, + debuggingEnabled: debuggingEnabled, + userAgent: userAgent, + ); +} + +Set _extractChannelNames(Set? channels) { + final Set channelNames = channels == null + ? {} + : channels.map((JavascriptChannel channel) => channel.name).toSet(); + return channelNames; +} + +class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { + _PlatformCallbacksHandler(this._widget); + + WebView _widget; + + @override + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) async { + final NavigationRequest request = + NavigationRequest._(url: url, isForMainFrame: isForMainFrame); + final bool allowNavigation = _widget.navigationDelegate == null || + await _widget.navigationDelegate!(request) == + NavigationDecision.navigate; + return allowNavigation; + } + + @override + void onPageStarted(String url) { + if (_widget.onPageStarted != null) { + _widget.onPageStarted!(url); + } + } + + @override + void onPageFinished(String url) { + if (_widget.onPageFinished != null) { + _widget.onPageFinished!(url); + } + } + + @override + void onProgress(int progress) { + if (_widget.onProgress != null) { + _widget.onProgress!(progress); + } + } + + void onWebResourceError(WebResourceError error) { + if (_widget.onWebResourceError != null) { + _widget.onWebResourceError!(error); + } + } +} + +/// Controls a [WebView]. +/// +/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] +/// callback for a [WebView] widget. +class WebViewController { + WebViewController._( + this._widget, + this._webViewPlatformController, + this._javascriptChannelRegistry, + ) : assert(_webViewPlatformController != null) { + _settings = _webSettingsFromWidget(_widget); + } + + final WebViewPlatformController _webViewPlatformController; + final JavascriptChannelRegistry _javascriptChannelRegistry; + + late WebSettings _settings; + + WebView _widget; + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, { + Map? headers, + }) async { + assert(url != null); + _validateUrlString(url); + return _webViewPlatformController.loadUrl(url, headers); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If [WebView.initialUrl] was never specified, returns `null`. + /// Note that this operation is asynchronous, and it is possible that the + /// current URL changes again by the time this function returns (in other + /// words, by the time this future completes, the WebView may be displaying a + /// different URL). + Future currentUrl() { + return _webViewPlatformController.currentUrl(); + } + + /// Checks whether there's a back history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has + /// changed by the time the future completed. + Future canGoBack() { + return _webViewPlatformController.canGoBack(); + } + + /// Checks whether there's a forward history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has + /// changed by the time the future completed. + Future canGoForward() { + return _webViewPlatformController.canGoForward(); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + return _webViewPlatformController.goBack(); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + return _webViewPlatformController.goForward(); + } + + /// Reloads the current URL. + Future reload() { + return _webViewPlatformController.reload(); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + /// + /// Note: Calling this method also triggers a reload. + Future clearCache() async { + await _webViewPlatformController.clearCache(); + return reload(); + } + + Future _updateJavascriptChannels( + Set? newChannels) async { + final Set currentChannels = + _javascriptChannelRegistry.channels.keys.toSet(); + final Set newChannelNames = _extractChannelNames(newChannels); + final Set channelsToAdd = + newChannelNames.difference(currentChannels); + final Set channelsToRemove = + currentChannels.difference(newChannelNames); + if (channelsToRemove.isNotEmpty) { + await _webViewPlatformController + .removeJavascriptChannels(channelsToRemove); + } + if (channelsToAdd.isNotEmpty) { + await _webViewPlatformController.addJavascriptChannels(channelsToAdd); + } + _javascriptChannelRegistry.updateJavascriptChannelsFromSet(newChannels); + } + + Future _updateWidget(WebView widget) async { + _widget = widget; + await _updateSettings(_webSettingsFromWidget(widget)); + await _updateJavascriptChannels(widget.javascriptChannels); + } + + Future _updateSettings(WebSettings newSettings) { + final WebSettings update = + _clearUnchangedWebSettings(_settings, newSettings); + _settings = newSettings; + return _webViewPlatformController.updateSettings(update); + } + + /// Evaluates a JavaScript expression in the context of the current page. + /// + /// On Android returns the evaluation result as a JSON formatted string. + /// + /// On iOS depending on the value type the return value would be one of: + /// + /// - For primitive JavaScript types: the value string formatted (e.g JavaScript 100 returns '100'). + /// - For JavaScript arrays of supported types: a string formatted NSArray(e.g '(1,2,3), note that the string for NSArray is formatted and might contain newlines and extra spaces.'). + /// - Other non-primitive types are not supported on iOS and will complete the Future with an error. + /// + /// The Future completes with an error if a JavaScript error occurred, or on iOS, if the type of the + /// evaluated expression is not supported as described above. + /// + /// When evaluating Javascript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the Javascript + /// embedded in the main frame HTML has been loaded. + Future evaluateJavascript(String javascriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); + } + // TODO(amirh): remove this on when the invokeMethod update makes it to stable Flutter. + // https://github.com/flutter/flutter/issues/26431 + // ignore: strong_mode_implicit_dynamic_method + return _webViewPlatformController.evaluateJavascript(javascriptString); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + return _webViewPlatformController.getTitle(); + } + + /// Sets the WebView's content scroll position. + /// + /// The parameters `x` and `y` specify the scroll position in WebView pixels. + Future scrollTo(int x, int y) { + return _webViewPlatformController.scrollTo(x, y); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. + Future scrollBy(int x, int y) { + return _webViewPlatformController.scrollBy(x, y); + } + + /// Return the horizontal scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + return _webViewPlatformController.getScrollX(); + } + + /// Return the vertical scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + return _webViewPlatformController.getScrollY(); + } +} + +/// Manages cookies pertaining to all [WebView]s. +class CookieManager { + /// Creates a [CookieManager] -- returns the instance if it's already been called. + factory CookieManager() { + return _instance ??= CookieManager._(); + } + + CookieManager._(); + + static CookieManager? _instance; + + /// Clears all cookies for all [WebView] instances. + /// + /// This is a no op on iOS version smaller than 9. + /// + /// Returns true if cookies were present before clearing, else false. + Future clearCookies() => WebView.platform.clearCookies(); +} + +// Throws an ArgumentError if `url` is not a valid URL string. +void _validateUrlString(String url) { + try { + final Uri uri = Uri.parse(url); + if (uri.scheme.isEmpty) { + throw ArgumentError('Missing scheme in URL string: "$url"'); + } + } on FormatException catch (e) { + throw ArgumentError(e); + } +} diff --git a/packages/webview_flutter/webview_flutter/lib/src/webview_android.dart b/packages/webview_flutter/webview_flutter/lib/src/webview_android.dart deleted file mode 100644 index ca1440d69929..000000000000 --- a/packages/webview_flutter/webview_flutter/lib/src/webview_android.dart +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2013 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:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -import '../platform_interface.dart'; -import 'webview_method_channel.dart'; - -/// Builds an Android webview. -/// -/// This is used as the default implementation for [WebView.platform] on Android. It uses -/// an [AndroidView] to embed the webview in the widget hierarchy, and uses a method channel to -/// communicate with the platform code. -class AndroidWebView implements WebViewPlatform { - @override - Widget build({ - required BuildContext context, - required CreationParams creationParams, - required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, - WebViewPlatformCreatedCallback? onWebViewPlatformCreated, - Set>? gestureRecognizers, - }) { - assert(webViewPlatformCallbacksHandler != null); - return GestureDetector( - // We prevent text selection by intercepting the long press event. - // This is a temporary stop gap due to issues with text selection on Android: - // https://github.com/flutter/flutter/issues/24585 - the text selection - // dialog is not responding to touch events. - // https://github.com/flutter/flutter/issues/24584 - the text selection - // handles are not showing. - // TODO(amirh): remove this when the issues above are fixed. - onLongPress: () {}, - excludeFromSemantics: true, - child: AndroidView( - viewType: 'plugins.flutter.io/webview', - onPlatformViewCreated: (int id) { - if (onWebViewPlatformCreated == null) { - return; - } - onWebViewPlatformCreated(MethodChannelWebViewPlatform( - id, webViewPlatformCallbacksHandler)); - }, - gestureRecognizers: gestureRecognizers, - layoutDirection: Directionality.maybeOf(context) ?? TextDirection.rtl, - creationParams: - MethodChannelWebViewPlatform.creationParamsToMap(creationParams), - creationParamsCodec: const StandardMessageCodec(), - ), - ); - } - - @override - Future clearCookies() => MethodChannelWebViewPlatform.clearCookies(); -} diff --git a/packages/webview_flutter/webview_flutter/lib/src/webview_cupertino.dart b/packages/webview_flutter/webview_flutter/lib/src/webview_cupertino.dart deleted file mode 100644 index 8d4be3800a28..000000000000 --- a/packages/webview_flutter/webview_flutter/lib/src/webview_cupertino.dart +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2013 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:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -import '../platform_interface.dart'; -import 'webview_method_channel.dart'; - -/// Builds an iOS webview. -/// -/// This is used as the default implementation for [WebView.platform] on iOS. It uses -/// a [UiKitView] to embed the webview in the widget hierarchy, and uses a method channel to -/// communicate with the platform code. -class CupertinoWebView implements WebViewPlatform { - @override - Widget build({ - required BuildContext context, - required CreationParams creationParams, - required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, - WebViewPlatformCreatedCallback? onWebViewPlatformCreated, - Set>? gestureRecognizers, - }) { - return UiKitView( - viewType: 'plugins.flutter.io/webview', - onPlatformViewCreated: (int id) { - if (onWebViewPlatformCreated == null) { - return; - } - onWebViewPlatformCreated( - MethodChannelWebViewPlatform(id, webViewPlatformCallbacksHandler)); - }, - gestureRecognizers: gestureRecognizers, - creationParams: - MethodChannelWebViewPlatform.creationParamsToMap(creationParams), - creationParamsCodec: const StandardMessageCodec(), - ); - } - - @override - Future clearCookies() => MethodChannelWebViewPlatform.clearCookies(); -} diff --git a/packages/webview_flutter/webview_flutter/lib/src/webview_method_channel.dart b/packages/webview_flutter/webview_flutter/lib/src/webview_method_channel.dart deleted file mode 100644 index 05831a9d8794..000000000000 --- a/packages/webview_flutter/webview_flutter/lib/src/webview_method_channel.dart +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright 2013 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:async'; - -import 'package:flutter/services.dart'; - -import '../platform_interface.dart'; - -/// A [WebViewPlatformController] that uses a method channel to control the webview. -class MethodChannelWebViewPlatform implements WebViewPlatformController { - /// Constructs an instance that will listen for webviews broadcasting to the - /// given [id], using the given [WebViewPlatformCallbacksHandler]. - MethodChannelWebViewPlatform(int id, this._platformCallbacksHandler) - : assert(_platformCallbacksHandler != null), - _channel = MethodChannel('plugins.flutter.io/webview_$id') { - _channel.setMethodCallHandler(_onMethodCall); - } - - final WebViewPlatformCallbacksHandler _platformCallbacksHandler; - - final MethodChannel _channel; - - static const MethodChannel _cookieManagerChannel = - MethodChannel('plugins.flutter.io/cookie_manager'); - - Future _onMethodCall(MethodCall call) async { - switch (call.method) { - case 'javascriptChannelMessage': - final String channel = call.arguments['channel']!; - final String message = call.arguments['message']!; - _platformCallbacksHandler.onJavaScriptChannelMessage(channel, message); - return true; - case 'navigationRequest': - return await _platformCallbacksHandler.onNavigationRequest( - url: call.arguments['url']!, - isForMainFrame: call.arguments['isForMainFrame']!, - ); - case 'onPageFinished': - _platformCallbacksHandler.onPageFinished(call.arguments['url']!); - return null; - case 'onProgress': - _platformCallbacksHandler.onProgress(call.arguments['progress']); - return null; - case 'onPageStarted': - _platformCallbacksHandler.onPageStarted(call.arguments['url']!); - return null; - case 'onWebResourceError': - _platformCallbacksHandler.onWebResourceError( - WebResourceError( - errorCode: call.arguments['errorCode']!, - description: call.arguments['description']!, - // iOS doesn't support `failingUrl`. - failingUrl: call.arguments['failingUrl'], - domain: call.arguments['domain'], - errorType: call.arguments['errorType'] == null - ? null - : WebResourceErrorType.values.firstWhere( - (WebResourceErrorType type) { - return type.toString() == - '$WebResourceErrorType.${call.arguments['errorType']}'; - }, - ), - ), - ); - return null; - } - - throw MissingPluginException( - '${call.method} was invoked but has no handler', - ); - } - - @override - Future loadUrl( - String url, - Map? headers, - ) async { - assert(url != null); - return _channel.invokeMethod('loadUrl', { - 'url': url, - 'headers': headers, - }); - } - - @override - Future currentUrl() => _channel.invokeMethod('currentUrl'); - - @override - Future canGoBack() => - _channel.invokeMethod("canGoBack").then((result) => result!); - - @override - Future canGoForward() => - _channel.invokeMethod("canGoForward").then((result) => result!); - - @override - Future goBack() => _channel.invokeMethod("goBack"); - - @override - Future goForward() => _channel.invokeMethod("goForward"); - - @override - Future reload() => _channel.invokeMethod("reload"); - - @override - Future clearCache() => _channel.invokeMethod("clearCache"); - - @override - Future updateSettings(WebSettings settings) async { - final Map updatesMap = _webSettingsToMap(settings); - if (updatesMap.isNotEmpty) { - await _channel.invokeMethod('updateSettings', updatesMap); - } - } - - @override - Future evaluateJavascript(String javascriptString) { - return _channel - .invokeMethod('evaluateJavascript', javascriptString) - .then((result) => result!); - } - - @override - Future addJavascriptChannels(Set javascriptChannelNames) { - return _channel.invokeMethod( - 'addJavascriptChannels', javascriptChannelNames.toList()); - } - - @override - Future removeJavascriptChannels(Set javascriptChannelNames) { - return _channel.invokeMethod( - 'removeJavascriptChannels', javascriptChannelNames.toList()); - } - - @override - Future getTitle() => _channel.invokeMethod("getTitle"); - - @override - Future scrollTo(int x, int y) { - return _channel.invokeMethod('scrollTo', { - 'x': x, - 'y': y, - }); - } - - @override - Future scrollBy(int x, int y) { - return _channel.invokeMethod('scrollBy', { - 'x': x, - 'y': y, - }); - } - - @override - Future getScrollX() => - _channel.invokeMethod("getScrollX").then((result) => result!); - - @override - Future getScrollY() => - _channel.invokeMethod("getScrollY").then((result) => result!); - - /// Method channel implementation for [WebViewPlatform.clearCookies]. - static Future clearCookies() { - return _cookieManagerChannel - .invokeMethod('clearCookies') - .then((dynamic result) => result!); - } - - static Map _webSettingsToMap(WebSettings? settings) { - final Map map = {}; - void _addIfNonNull(String key, dynamic value) { - if (value == null) { - return; - } - map[key] = value; - } - - void _addSettingIfPresent(String key, WebSetting setting) { - if (!setting.isPresent) { - return; - } - map[key] = setting.value; - } - - _addIfNonNull('jsMode', settings!.javascriptMode?.index); - _addIfNonNull('hasNavigationDelegate', settings.hasNavigationDelegate); - _addIfNonNull('hasProgressTracking', settings.hasProgressTracking); - _addIfNonNull('debuggingEnabled', settings.debuggingEnabled); - _addIfNonNull( - 'gestureNavigationEnabled', settings.gestureNavigationEnabled); - _addIfNonNull( - 'allowsInlineMediaPlayback', settings.allowsInlineMediaPlayback); - _addSettingIfPresent('userAgent', settings.userAgent); - return map; - } - - /// Converts a [CreationParams] object to a map as expected by `platform_views` channel. - /// - /// This is used for the `creationParams` argument of the platform views created by - /// [AndroidWebViewBuilder] and [CupertinoWebViewBuilder]. - static Map creationParamsToMap( - CreationParams creationParams, { - bool usesHybridComposition = false, - }) { - return { - 'initialUrl': creationParams.initialUrl, - 'settings': _webSettingsToMap(creationParams.webSettings), - 'javascriptChannelNames': creationParams.javascriptChannelNames.toList(), - 'userAgent': creationParams.userAgent, - 'autoMediaPlaybackPolicy': creationParams.autoMediaPlaybackPolicy.index, - 'usesHybridComposition': usesHybridComposition, - }; - } -} diff --git a/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart index 398ac876bf3e..ba38771e5107 100644 --- a/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart @@ -2,833 +2,9 @@ // 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:io'; +export 'package:webview_flutter_android/webview_android.dart'; +export 'package:webview_flutter_android/webview_surface_android.dart'; +export 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -import 'platform_interface.dart'; -import 'src/webview_android.dart'; -import 'src/webview_cupertino.dart'; -import 'src/webview_method_channel.dart'; - -/// Optional callback invoked when a web view is first created. [controller] is -/// the [WebViewController] for the created web view. -typedef void WebViewCreatedCallback(WebViewController controller); - -/// Describes the state of JavaScript support in a given web view. -enum JavascriptMode { - /// JavaScript execution is disabled. - disabled, - - /// JavaScript execution is not restricted. - unrestricted, -} - -/// A message that was sent by JavaScript code running in a [WebView]. -class JavascriptMessage { - /// Constructs a JavaScript message object. - /// - /// The `message` parameter must not be null. - const JavascriptMessage(this.message) : assert(message != null); - - /// The contents of the message that was sent by the JavaScript code. - final String message; -} - -/// Callback type for handling messages sent from Javascript running in a web view. -typedef void JavascriptMessageHandler(JavascriptMessage message); - -/// Information about a navigation action that is about to be executed. -class NavigationRequest { - NavigationRequest._({required this.url, required this.isForMainFrame}); - - /// The URL that will be loaded if the navigation is executed. - final String url; - - /// Whether the navigation request is to be loaded as the main frame. - final bool isForMainFrame; - - @override - String toString() { - return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; - } -} - -/// A decision on how to handle a navigation request. -enum NavigationDecision { - /// Prevent the navigation from taking place. - prevent, - - /// Allow the navigation to take place. - navigate, -} - -/// Android [WebViewPlatform] that uses [AndroidViewSurface] to build the [WebView] widget. -/// -/// To use this, set [WebView.platform] to an instance of this class. -/// -/// This implementation uses hybrid composition to render the [WebView] on -/// Android. It solves multiple issues related to accessibility and interaction -/// with the [WebView] at the cost of some performance on Android versions below -/// 10. See https://github.com/flutter/flutter/wiki/Hybrid-Composition for more -/// information. -class SurfaceAndroidWebView extends AndroidWebView { - @override - Widget build({ - required BuildContext context, - required CreationParams creationParams, - WebViewPlatformCreatedCallback? onWebViewPlatformCreated, - Set>? gestureRecognizers, - required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, - }) { - assert(Platform.isAndroid); - assert(webViewPlatformCallbacksHandler != null); - return PlatformViewLink( - viewType: 'plugins.flutter.io/webview', - surfaceFactory: ( - BuildContext context, - PlatformViewController controller, - ) { - return AndroidViewSurface( - controller: controller as AndroidViewController, - gestureRecognizers: gestureRecognizers ?? - const >{}, - hitTestBehavior: PlatformViewHitTestBehavior.opaque, - ); - }, - onCreatePlatformView: (PlatformViewCreationParams params) { - return PlatformViewsService.initSurfaceAndroidView( - id: params.id, - viewType: 'plugins.flutter.io/webview', - // WebView content is not affected by the Android view's layout direction, - // we explicitly set it here so that the widget doesn't require an ambient - // directionality. - layoutDirection: TextDirection.rtl, - creationParams: MethodChannelWebViewPlatform.creationParamsToMap( - creationParams, - usesHybridComposition: true, - ), - creationParamsCodec: const StandardMessageCodec(), - ) - ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) - ..addOnPlatformViewCreatedListener((int id) { - if (onWebViewPlatformCreated == null) { - return; - } - onWebViewPlatformCreated( - MethodChannelWebViewPlatform(id, webViewPlatformCallbacksHandler), - ); - }) - ..create(); - }, - ); - } -} - -/// Decides how to handle a specific navigation request. -/// -/// The returned [NavigationDecision] determines how the navigation described by -/// `navigation` should be handled. -/// -/// See also: [WebView.navigationDelegate]. -typedef FutureOr NavigationDelegate( - NavigationRequest navigation); - -/// Signature for when a [WebView] has started loading a page. -typedef void PageStartedCallback(String url); - -/// Signature for when a [WebView] has finished loading a page. -typedef void PageFinishedCallback(String url); - -/// Signature for when a [WebView] is loading a page. -typedef void PageLoadingCallback(int progress); - -/// Signature for when a [WebView] has failed to load a resource. -typedef void WebResourceErrorCallback(WebResourceError error); - -/// Specifies possible restrictions on automatic media playback. -/// -/// This is typically used in [WebView.initialMediaPlaybackPolicy]. -// The method channel implementation is marshalling this enum to the value's index, so the order -// is important. -enum AutoMediaPlaybackPolicy { - /// Starting any kind of media playback requires a user action. - /// - /// For example: JavaScript code cannot start playing media unless the code was executed - /// as a result of a user action (like a touch event). - require_user_action_for_all_media_types, - - /// Starting any kind of media playback is always allowed. - /// - /// For example: JavaScript code that's triggered when the page is loaded can start playing - /// video or audio. - always_allow, -} - -final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9_]*\$'); - -/// A named channel for receiving messaged from JavaScript code running inside a web view. -class JavascriptChannel { - /// Constructs a Javascript channel. - /// - /// The parameters `name` and `onMessageReceived` must not be null. - JavascriptChannel({ - required this.name, - required this.onMessageReceived, - }) : assert(name != null), - assert(onMessageReceived != null), - assert(_validChannelNames.hasMatch(name)); - - /// The channel's name. - /// - /// Passing this channel object as part of a [WebView.javascriptChannels] adds a channel object to - /// the Javascript window object's property named `name`. - /// - /// The name must start with a letter or underscore(_), followed by any combination of those - /// characters plus digits. - /// - /// Note that any JavaScript existing `window` property with this name will be overriden. - /// - /// See also [WebView.javascriptChannels] for more details on the channel registration mechanism. - final String name; - - /// A callback that's invoked when a message is received through the channel. - final JavascriptMessageHandler onMessageReceived; -} - -/// A web view widget for showing html content. -/// -/// There is a known issue that on iOS 13.4 and 13.5, other flutter widgets covering -/// the `WebView` is not able to block the `WebView` from receiving touch events. -/// See https://github.com/flutter/flutter/issues/53490. -class WebView extends StatefulWidget { - /// Creates a new web view. - /// - /// The web view can be controlled using a `WebViewController` that is passed to the - /// `onWebViewCreated` callback once the web view is created. - /// - /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. - const WebView({ - Key? key, - this.onWebViewCreated, - this.initialUrl, - this.javascriptMode = JavascriptMode.disabled, - this.javascriptChannels, - this.navigationDelegate, - this.gestureRecognizers, - this.onPageStarted, - this.onPageFinished, - this.onProgress, - this.onWebResourceError, - this.debuggingEnabled = false, - this.gestureNavigationEnabled = false, - this.userAgent, - this.initialMediaPlaybackPolicy = - AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, - this.allowsInlineMediaPlayback = false, - }) : assert(javascriptMode != null), - assert(initialMediaPlaybackPolicy != null), - assert(allowsInlineMediaPlayback != null), - super(key: key); - - static WebViewPlatform? _platform; - - /// Sets a custom [WebViewPlatform]. - /// - /// This property can be set to use a custom platform implementation for WebViews. - /// - /// Setting `platform` doesn't affect [WebView]s that were already created. - /// - /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. - static set platform(WebViewPlatform? platform) { - _platform = platform; - } - - /// The WebView platform that's used by this WebView. - /// - /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. - static WebViewPlatform get platform { - if (_platform == null) { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - _platform = AndroidWebView(); - break; - case TargetPlatform.iOS: - _platform = CupertinoWebView(); - break; - default: - throw UnsupportedError( - "Trying to use the default webview implementation for $defaultTargetPlatform but there isn't a default one"); - } - } - return _platform!; - } - - /// If not null invoked once the web view is created. - final WebViewCreatedCallback? onWebViewCreated; - - /// Which gestures should be consumed by the web view. - /// - /// It is possible for other gesture recognizers to be competing with the web view on pointer - /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle - /// vertical drags. The web view will claim gestures that are recognized by any of the - /// recognizers on this list. - /// - /// When this set is empty or null, the web view will only handle pointer events for gestures that - /// were not claimed by any other gesture recognizer. - final Set>? gestureRecognizers; - - /// The initial URL to load. - final String? initialUrl; - - /// Whether Javascript execution is enabled. - final JavascriptMode javascriptMode; - - /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. - /// - /// For each [JavascriptChannel] in the set, a channel object is made available for the - /// JavaScript code in a window property named [JavascriptChannel.name]. - /// The JavaScript code can then call `postMessage` on that object to send a message that will be - /// passed to [JavascriptChannel.onMessageReceived]. - /// - /// For example for the following JavascriptChannel: - /// - /// ```dart - /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); - /// ``` - /// - /// JavaScript code can call: - /// - /// ```javascript - /// Print.postMessage('Hello'); - /// ``` - /// - /// To asynchronously invoke the message handler which will print the message to standard output. - /// - /// Adding a new JavaScript channel only takes affect after the next page is loaded. - /// - /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple - /// channels in the list. - /// - /// A null value is equivalent to an empty set. - final Set? javascriptChannels; - - /// A delegate function that decides how to handle navigation actions. - /// - /// When a navigation is initiated by the WebView (e.g when a user clicks a link) - /// this delegate is called and has to decide how to proceed with the navigation. - /// - /// See [NavigationDecision] for possible decisions the delegate can take. - /// - /// When null all navigation actions are allowed. - /// - /// Caveats on Android: - /// - /// * Navigation actions targeted to the main frame can be intercepted, - /// navigation actions targeted to subframes are allowed regardless of the value - /// returned by this delegate. - /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were - /// triggered by a user gesture, this disables some of Chromium's security mechanisms. - /// A navigationDelegate should only be set when loading trusted content. - /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have - /// a later version): - /// * When a navigationDelegate is set pages with frames are not properly handled by the - /// webview, and frames will be opened in the main frame. - /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. - final NavigationDelegate? navigationDelegate; - - /// Controls whether inline playback of HTML5 videos is allowed on iOS. - /// - /// This field is ignored on Android because Android allows it by default. - /// - /// By default `allowsInlineMediaPlayback` is false. - final bool allowsInlineMediaPlayback; - - /// Invoked when a page starts loading. - final PageStartedCallback? onPageStarted; - - /// Invoked when a page has finished loading. - /// - /// This is invoked only for the main frame. - /// - /// When [onPageFinished] is invoked on Android, the page being rendered may - /// not be updated yet. - /// - /// When invoked on iOS or Android, any Javascript code that is embedded - /// directly in the HTML has been loaded and code injected with - /// [WebViewController.evaluateJavascript] can assume this. - final PageFinishedCallback? onPageFinished; - - /// Invoked when a page is loading. - final PageLoadingCallback? onProgress; - - /// Invoked when a web resource has failed to load. - /// - /// This callback is only called for the main page. - final WebResourceErrorCallback? onWebResourceError; - - /// Controls whether WebView debugging is enabled. - /// - /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). - /// - /// WebView debugging is enabled by default in dev builds on iOS. - /// - /// To debug WebViews on iOS: - /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) - /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> - /// - /// By default `debuggingEnabled` is false. - final bool debuggingEnabled; - - /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. - /// - /// This only works on iOS. - /// - /// By default `gestureNavigationEnabled` is false. - final bool gestureNavigationEnabled; - - /// The value used for the HTTP User-Agent: request header. - /// - /// When null the platform's webview default is used for the User-Agent header. - /// - /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. - /// - /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. - /// - /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom - /// user agent. - /// - /// By default `userAgent` is null. - final String? userAgent; - - /// Which restrictions apply on automatic media playback. - /// - /// This initial value is applied to the platform's webview upon creation. Any following - /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). - /// - /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. - final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; - - @override - State createState() => _WebViewState(); -} - -class _WebViewState extends State { - final Completer _controller = - Completer(); - - late _PlatformCallbacksHandler _platformCallbacksHandler; - - @override - Widget build(BuildContext context) { - return WebView.platform.build( - context: context, - onWebViewPlatformCreated: _onWebViewPlatformCreated, - webViewPlatformCallbacksHandler: _platformCallbacksHandler, - gestureRecognizers: widget.gestureRecognizers, - creationParams: _creationParamsfromWidget(widget), - ); - } - - @override - void initState() { - super.initState(); - _assertJavascriptChannelNamesAreUnique(); - _platformCallbacksHandler = _PlatformCallbacksHandler(widget); - } - - @override - void didUpdateWidget(WebView oldWidget) { - super.didUpdateWidget(oldWidget); - _assertJavascriptChannelNamesAreUnique(); - _controller.future.then((WebViewController controller) { - _platformCallbacksHandler._widget = widget; - controller._updateWidget(widget); - }); - } - - void _onWebViewPlatformCreated(WebViewPlatformController? webViewPlatform) { - final WebViewController controller = WebViewController._( - widget, webViewPlatform!, _platformCallbacksHandler); - _controller.complete(controller); - if (widget.onWebViewCreated != null) { - widget.onWebViewCreated!(controller); - } - } - - void _assertJavascriptChannelNamesAreUnique() { - if (widget.javascriptChannels == null || - widget.javascriptChannels!.isEmpty) { - return; - } - assert(_extractChannelNames(widget.javascriptChannels).length == - widget.javascriptChannels!.length); - } -} - -CreationParams _creationParamsfromWidget(WebView widget) { - return CreationParams( - initialUrl: widget.initialUrl, - webSettings: _webSettingsFromWidget(widget), - javascriptChannelNames: _extractChannelNames(widget.javascriptChannels), - userAgent: widget.userAgent, - autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, - ); -} - -WebSettings _webSettingsFromWidget(WebView widget) { - return WebSettings( - javascriptMode: widget.javascriptMode, - hasNavigationDelegate: widget.navigationDelegate != null, - hasProgressTracking: widget.onProgress != null, - debuggingEnabled: widget.debuggingEnabled, - gestureNavigationEnabled: widget.gestureNavigationEnabled, - allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, - userAgent: WebSetting.of(widget.userAgent), - ); -} - -// This method assumes that no fields in `currentValue` are null. -WebSettings _clearUnchangedWebSettings( - WebSettings currentValue, WebSettings newValue) { - assert(currentValue.javascriptMode != null); - assert(currentValue.hasNavigationDelegate != null); - assert(currentValue.hasProgressTracking != null); - assert(currentValue.debuggingEnabled != null); - assert(currentValue.userAgent != null); - assert(newValue.javascriptMode != null); - assert(newValue.hasNavigationDelegate != null); - assert(newValue.debuggingEnabled != null); - assert(newValue.userAgent != null); - - JavascriptMode? javascriptMode; - bool? hasNavigationDelegate; - bool? hasProgressTracking; - bool? debuggingEnabled; - WebSetting userAgent = WebSetting.absent(); - if (currentValue.javascriptMode != newValue.javascriptMode) { - javascriptMode = newValue.javascriptMode; - } - if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { - hasNavigationDelegate = newValue.hasNavigationDelegate; - } - if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { - hasProgressTracking = newValue.hasProgressTracking; - } - if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { - debuggingEnabled = newValue.debuggingEnabled; - } - if (currentValue.userAgent != newValue.userAgent) { - userAgent = newValue.userAgent; - } - - return WebSettings( - javascriptMode: javascriptMode, - hasNavigationDelegate: hasNavigationDelegate, - hasProgressTracking: hasProgressTracking, - debuggingEnabled: debuggingEnabled, - userAgent: userAgent, - ); -} - -Set _extractChannelNames(Set? channels) { - final Set channelNames = channels == null - ? {} - : channels.map((JavascriptChannel channel) => channel.name).toSet(); - return channelNames; -} - -class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { - _PlatformCallbacksHandler(this._widget) { - _updateJavascriptChannelsFromSet(_widget.javascriptChannels); - } - - WebView _widget; - - // Maps a channel name to a channel. - final Map _javascriptChannels = - {}; - - @override - void onJavaScriptChannelMessage(String channel, String message) { - _javascriptChannels[channel]!.onMessageReceived(JavascriptMessage(message)); - } - - @override - FutureOr onNavigationRequest({ - required String url, - required bool isForMainFrame, - }) async { - final NavigationRequest request = - NavigationRequest._(url: url, isForMainFrame: isForMainFrame); - final bool allowNavigation = _widget.navigationDelegate == null || - await _widget.navigationDelegate!(request) == - NavigationDecision.navigate; - return allowNavigation; - } - - @override - void onPageStarted(String url) { - if (_widget.onPageStarted != null) { - _widget.onPageStarted!(url); - } - } - - @override - void onPageFinished(String url) { - if (_widget.onPageFinished != null) { - _widget.onPageFinished!(url); - } - } - - @override - void onProgress(int progress) { - if (_widget.onProgress != null) { - _widget.onProgress!(progress); - } - } - - void onWebResourceError(WebResourceError error) { - if (_widget.onWebResourceError != null) { - _widget.onWebResourceError!(error); - } - } - - void _updateJavascriptChannelsFromSet(Set? channels) { - _javascriptChannels.clear(); - if (channels == null) { - return; - } - for (JavascriptChannel channel in channels) { - _javascriptChannels[channel.name] = channel; - } - } -} - -/// Controls a [WebView]. -/// -/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] -/// callback for a [WebView] widget. -class WebViewController { - WebViewController._( - this._widget, - this._webViewPlatformController, - this._platformCallbacksHandler, - ) : assert(_webViewPlatformController != null) { - _settings = _webSettingsFromWidget(_widget); - } - - final WebViewPlatformController _webViewPlatformController; - - final _PlatformCallbacksHandler _platformCallbacksHandler; - - late WebSettings _settings; - - WebView _widget; - - /// Loads the specified URL. - /// - /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will - /// be added as key value pairs of HTTP headers for the request. - /// - /// `url` must not be null. - /// - /// Throws an ArgumentError if `url` is not a valid URL string. - Future loadUrl( - String url, { - Map? headers, - }) async { - assert(url != null); - _validateUrlString(url); - return _webViewPlatformController.loadUrl(url, headers); - } - - /// Accessor to the current URL that the WebView is displaying. - /// - /// If [WebView.initialUrl] was never specified, returns `null`. - /// Note that this operation is asynchronous, and it is possible that the - /// current URL changes again by the time this function returns (in other - /// words, by the time this future completes, the WebView may be displaying a - /// different URL). - Future currentUrl() { - return _webViewPlatformController.currentUrl(); - } - - /// Checks whether there's a back history item. - /// - /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has - /// changed by the time the future completed. - Future canGoBack() { - return _webViewPlatformController.canGoBack(); - } - - /// Checks whether there's a forward history item. - /// - /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has - /// changed by the time the future completed. - Future canGoForward() { - return _webViewPlatformController.canGoForward(); - } - - /// Goes back in the history of this WebView. - /// - /// If there is no back history item this is a no-op. - Future goBack() { - return _webViewPlatformController.goBack(); - } - - /// Goes forward in the history of this WebView. - /// - /// If there is no forward history item this is a no-op. - Future goForward() { - return _webViewPlatformController.goForward(); - } - - /// Reloads the current URL. - Future reload() { - return _webViewPlatformController.reload(); - } - - /// Clears all caches used by the [WebView]. - /// - /// The following caches are cleared: - /// 1. Browser HTTP Cache. - /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. - /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. - /// 3. Application cache. - /// 4. Local Storage. - /// - /// Note: Calling this method also triggers a reload. - Future clearCache() async { - await _webViewPlatformController.clearCache(); - return reload(); - } - - Future _updateWidget(WebView widget) async { - _widget = widget; - await _updateSettings(_webSettingsFromWidget(widget)); - await _updateJavascriptChannels(widget.javascriptChannels); - } - - Future _updateSettings(WebSettings newSettings) { - final WebSettings update = - _clearUnchangedWebSettings(_settings, newSettings); - _settings = newSettings; - return _webViewPlatformController.updateSettings(update); - } - - Future _updateJavascriptChannels( - Set? newChannels) async { - final Set currentChannels = - _platformCallbacksHandler._javascriptChannels.keys.toSet(); - final Set newChannelNames = _extractChannelNames(newChannels); - final Set channelsToAdd = - newChannelNames.difference(currentChannels); - final Set channelsToRemove = - currentChannels.difference(newChannelNames); - if (channelsToRemove.isNotEmpty) { - await _webViewPlatformController - .removeJavascriptChannels(channelsToRemove); - } - if (channelsToAdd.isNotEmpty) { - await _webViewPlatformController.addJavascriptChannels(channelsToAdd); - } - _platformCallbacksHandler._updateJavascriptChannelsFromSet(newChannels); - } - - /// Evaluates a JavaScript expression in the context of the current page. - /// - /// On Android returns the evaluation result as a JSON formatted string. - /// - /// On iOS depending on the value type the return value would be one of: - /// - /// - For primitive JavaScript types: the value string formatted (e.g JavaScript 100 returns '100'). - /// - For JavaScript arrays of supported types: a string formatted NSArray(e.g '(1,2,3), note that the string for NSArray is formatted and might contain newlines and extra spaces.'). - /// - Other non-primitive types are not supported on iOS and will complete the Future with an error. - /// - /// The Future completes with an error if a JavaScript error occurred, or on iOS, if the type of the - /// evaluated expression is not supported as described above. - /// - /// When evaluating Javascript in a [WebView], it is best practice to wait for - /// the [WebView.onPageFinished] callback. This guarantees all the Javascript - /// embedded in the main frame HTML has been loaded. - Future evaluateJavascript(String javascriptString) { - if (_settings.javascriptMode == JavascriptMode.disabled) { - return Future.error(FlutterError( - 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); - } - // TODO(amirh): remove this on when the invokeMethod update makes it to stable Flutter. - // https://github.com/flutter/flutter/issues/26431 - // ignore: strong_mode_implicit_dynamic_method - return _webViewPlatformController.evaluateJavascript(javascriptString); - } - - /// Returns the title of the currently loaded page. - Future getTitle() { - return _webViewPlatformController.getTitle(); - } - - /// Sets the WebView's content scroll position. - /// - /// The parameters `x` and `y` specify the scroll position in WebView pixels. - Future scrollTo(int x, int y) { - return _webViewPlatformController.scrollTo(x, y); - } - - /// Move the scrolled position of this view. - /// - /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. - Future scrollBy(int x, int y) { - return _webViewPlatformController.scrollBy(x, y); - } - - /// Return the horizontal scroll position, in WebView pixels, of this view. - /// - /// Scroll position is measured from left. - Future getScrollX() { - return _webViewPlatformController.getScrollX(); - } - - /// Return the vertical scroll position, in WebView pixels, of this view. - /// - /// Scroll position is measured from top. - Future getScrollY() { - return _webViewPlatformController.getScrollY(); - } -} - -/// Manages cookies pertaining to all [WebView]s. -class CookieManager { - /// Creates a [CookieManager] -- returns the instance if it's already been called. - factory CookieManager() { - return _instance ??= CookieManager._(); - } - - CookieManager._(); - - static CookieManager? _instance; - - /// Clears all cookies for all [WebView] instances. - /// - /// This is a no op on iOS version smaller than 9. - /// - /// Returns true if cookies were present before clearing, else false. - Future clearCookies() => WebView.platform.clearCookies(); -} - -// Throws an ArgumentError if `url` is not a valid URL string. -void _validateUrlString(String url) { - try { - final Uri uri = Uri.parse(url); - if (uri.scheme.isEmpty) { - throw ArgumentError('Missing scheme in URL string: "$url"'); - } - } on FormatException catch (e) { - throw ArgumentError(e); - } -} +export 'platform_interface.dart'; +export 'src/webview.dart'; diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index 393a66e3f92e..985f3178916f 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -12,14 +12,16 @@ flutter: plugin: platforms: android: - package: io.flutter.plugins.webviewflutter - pluginClass: WebViewFlutterPlugin + default_package: webview_flutter_android ios: - pluginClass: FLTWebViewFlutterPlugin + default_package: webview_flutter_wkwebview dependencies: flutter: sdk: flutter + webview_flutter_platform_interface: ^1.0.0 + webview_flutter_android: ^2.0.13 + webview_flutter_wkwebview: ^2.0.13 dev_dependencies: flutter_driver: diff --git a/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart index 5efee6d9952d..f7d09266a64b 100644 --- a/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart @@ -10,7 +10,7 @@ import 'package:flutter/src/foundation/basic_types.dart'; import 'package:flutter/src/gestures/recognizer.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:webview_flutter/platform_interface.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; import 'package:webview_flutter/webview_flutter.dart'; typedef void VoidCallback(); @@ -1175,6 +1175,7 @@ class MyWebViewPlatform implements WebViewPlatform { BuildContext? context, CreationParams? creationParams, required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, WebViewPlatformCreatedCallback? onWebViewPlatformCreated, Set>? gestureRecognizers, }) { From 8c5f0f04cade805176358db125ba9fc1b2b25fc3 Mon Sep 17 00:00:00 2001 From: Baw Appie Date: Fri, 24 Sep 2021 00:28:12 +0900 Subject: [PATCH 316/364] [url_launcher] Error handling when URL cannot be parsed with Uri.parse (#4365) --- .../url_launcher/url_launcher/CHANGELOG.md | 4 +++ .../url_launcher/lib/url_launcher.dart | 6 ++-- .../url_launcher/url_launcher/pubspec.yaml | 2 +- .../url_launcher/test/url_launcher_test.dart | 36 +++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/url_launcher/url_launcher/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 4b52a8d46f8b..fff325e08915 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.0.12 + +* Fixed an error where 'launch' method of url_launcher would cause an error if the provided URL was not valid by RFC 3986. + ## 6.0.11 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index 239e3c46c480..300f96f4a179 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -71,8 +71,10 @@ Future launch( Brightness? statusBarBrightness, String? webOnlyWindowName, }) async { - final Uri url = Uri.parse(urlString.trimLeft()); - final bool isWebURL = url.scheme == 'http' || url.scheme == 'https'; + final Uri? url = Uri.tryParse(urlString.trimLeft()); + final bool isWebURL = + url != null && (url.scheme == 'http' || url.scheme == 'https'); + if ((forceSafariVC == true || forceWebView == true) && !isWebURL) { throw PlatformException( code: 'NOT_A_WEB_SCHEME', diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index c90d2feb08f4..8edb9e21c535 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. repository: https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.11 +version: 6.0.12 environment: sdk: ">=2.14.0 <3.0.0" diff --git a/packages/url_launcher/url_launcher/test/url_launcher_test.dart b/packages/url_launcher/url_launcher/test/url_launcher_test.dart index 04f727a57746..a038746d6bec 100644 --- a/packages/url_launcher/url_launcher/test/url_launcher_test.dart +++ b/packages/url_launcher/url_launcher/test/url_launcher_test.dart @@ -281,6 +281,42 @@ void main() { await launchResult; expect(binding.renderView.automaticSystemUiAdjustment, true); }); + + test('open non-parseable url', () async { + mock + ..setLaunchExpectations( + url: + 'rdp://full%20address=s:mypc:3389&audiomode=i:2&disable%20themes=i:1', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: {}, + webOnlyWindowName: null, + ) + ..setResponse(true); + expect( + await launch( + 'rdp://full%20address=s:mypc:3389&audiomode=i:2&disable%20themes=i:1'), + isTrue); + }); + + test('cannot open non-parseable url with forceSafariVC: true', () async { + expect( + () async => await launch( + 'rdp://full%20address=s:mypc:3389&audiomode=i:2&disable%20themes=i:1', + forceSafariVC: true), + throwsA(isA())); + }); + + test('cannot open non-parseable url with forceWebView: true', () async { + expect( + () async => await launch( + 'rdp://full%20address=s:mypc:3389&audiomode=i:2&disable%20themes=i:1', + forceWebView: true), + throwsA(isA())); + }); }); } From cdabfa9cf7a16fb439667d202e3b5fe9d99036b0 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 23 Sep 2021 13:23:06 -0400 Subject: [PATCH 317/364] [flutter_plugin_tools] Allow overriding breaking change check (#4369) --- .cirrus.yml | 16 +++- script/tool/CHANGELOG.md | 2 + .../lib/src/common/repository_package.dart | 5 + .../tool/lib/src/drive_examples_command.dart | 2 +- .../src/federation_safety_check_command.dart | 2 +- .../tool/lib/src/version_check_command.dart | 83 +++++++++++++++-- .../tool/test/version_check_command_test.dart | 91 ++++++++++++++++++- 7 files changed, 188 insertions(+), 13 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index 8dcb4d96d2be..67343ee15f88 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -68,7 +68,21 @@ task: - cd script/tool - dart pub run test - name: publishable - version_check_script: ./script/tool_runner.sh version-check + env: + CHANGE_DESC: "$TMPDIR/change-description.txt" + version_check_script: + # For pre-submit, pass the PR description to the script to allow for + # platform version breaking version change justifications. + # For post-submit, ignore platform version breaking version changes. + # The PR description isn't reliably part of the commit message, so using + # the same flags as for presubmit would likely result in false-positive + # post-submit failures. + - if [[ $CIRRUS_PR == "" ]]; then + - ./script/tool_runner.sh version-check --ignore-platform-interface-breaks + - else + - echo "$CIRRUS_CHANGE_MESSAGE" > "$CHANGE_DESC" + - ./script/tool_runner.sh version-check --change-description-file="$CHANGE_DESC" + - fi publish_check_script: ./script/tool_runner.sh publish-check - name: format format_script: ./script/tool_runner.sh format --fail-on-change diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 7e9cd3bec938..2bc7a901a9a2 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -6,6 +6,8 @@ federated packages that have been done in such a way that they will pass in CI, but fail once the change is landed and published. - `publish-check` now validates that there is an `AUTHORS` file. +- Added flags to `version-check` to allow overriding the platform interface + major version change restriction. ## 0.7.1 diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart index feece7c1cdff..cb586afb4dfe 100644 --- a/script/tool/lib/src/common/repository_package.dart +++ b/script/tool/lib/src/common/repository_package.dart @@ -53,6 +53,11 @@ class RepositoryPackage { directory.parent.basename != 'packages' && directory.basename.startsWith(directory.parent.basename); + /// True if this appears to be a platform interface package, according to + /// repository conventions. + bool get isPlatformInterface => + directory.basename.endsWith('_platform_interface'); + /// Returns the Flutter example packages contained in the package, if any. Iterable getExamples() { final Directory exampleDirectory = directory.childDirectory('example'); diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index b3434b0659f3..593e557fa395 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -133,7 +133,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { @override Future runForPackage(RepositoryPackage package) async { - if (package.directory.basename.endsWith('_platform_interface') && + if (package.isPlatformInterface && !package.getSingleExampleDeprecated().directory.existsSync()) { // Platform interface packages generally aren't intended to have // examples, and don't need integration tests, so skip rather than fail. diff --git a/script/tool/lib/src/federation_safety_check_command.dart b/script/tool/lib/src/federation_safety_check_command.dart index fd53d6cbaa67..200f9c3f48cb 100644 --- a/script/tool/lib/src/federation_safety_check_command.dart +++ b/script/tool/lib/src/federation_safety_check_command.dart @@ -109,7 +109,7 @@ class FederationSafetyCheckCommand extends PackageLoopingCommand { return PackageResult.skip('Not a federated plugin.'); } - if (package.directory.basename.endsWith('_platform_interface')) { + if (package.isPlatformInterface) { // As the leaf nodes in the graph, a published package interface change is // assumed to be correct, and other changes are validated against that. return PackageResult.skip( diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 6b49c40d66bb..528251fbf80d 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -18,6 +18,8 @@ import 'common/process_runner.dart'; import 'common/pub_version_finder.dart'; import 'common/repository_package.dart'; +const int _exitMissingChangeDescriptionFile = 3; + /// Categories of version change types. enum NextVersionType { /// A breaking change. @@ -108,13 +110,36 @@ class VersionCheckCommand extends PackageLoopingCommand { argParser.addFlag( _againstPubFlag, help: 'Whether the version check should run against the version on pub.\n' - 'Defaults to false, which means the version check only run against the previous version in code.', + 'Defaults to false, which means the version check only run against ' + 'the previous version in code.', defaultsTo: false, negatable: true, ); + argParser.addOption(_changeDescriptionFile, + help: 'The path to a file containing the description of the change ' + '(e.g., PR description or commit message).\n\n' + 'If supplied, this is used to allow overrides to some version ' + 'checks.'); + argParser.addFlag(_ignorePlatformInterfaceBreaks, + help: 'Bypasses the check that platform interfaces do not contain ' + 'breaking changes.\n\n' + 'This is only intended for use in post-submit CI checks, to ' + 'prevent the possibility of post-submit breakage if a change ' + 'description justification is not transferred into the commit ' + 'message. Pre-submit checks should always use ' + '--$_changeDescriptionFile instead.', + hide: true); } static const String _againstPubFlag = 'against-pub'; + static const String _changeDescriptionFile = 'change-description-file'; + static const String _ignorePlatformInterfaceBreaks = + 'ignore-platform-interface-breaks'; + + /// The string that must be in [_changeDescriptionFile] to allow a breaking + /// change to a platform interface. + static const String _breakingChangeJustificationMarker = + '## Breaking change justification'; final PubVersionFinder _pubVersionFinder; @@ -292,16 +317,17 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} return _CurrentVersionState.invalidChange; } - final bool isPlatformInterface = - pubspec.name.endsWith('_platform_interface'); - // TODO(stuartmorgan): Relax this check. See - // https://github.com/flutter/flutter/issues/85391 - if (isPlatformInterface && - allowedNextVersions[currentVersion] == NextVersionType.BREAKING_MAJOR) { + if (allowedNextVersions[currentVersion] == NextVersionType.BREAKING_MAJOR && + !_validateBreakingChange(package)) { printError('${indentation}Breaking change detected.\n' - '${indentation}Breaking changes to platform interfaces are strongly discouraged.\n'); + '${indentation}Breaking changes to platform interfaces are not ' + 'allowed without explicit justification.\n' + '${indentation}See ' + 'https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages ' + 'for more information.'); return _CurrentVersionState.invalidChange; } + return _CurrentVersionState.validChange; } @@ -398,4 +424,45 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog. return null; } } + + /// Checks whether the current breaking change to [package] should be allowed, + /// logging extra information for auditing when allowing unusual cases. + bool _validateBreakingChange(RepositoryPackage package) { + // Only platform interfaces have breaking change restrictions. + if (!package.isPlatformInterface) { + return true; + } + + if (getBoolArg(_ignorePlatformInterfaceBreaks)) { + logWarning( + '${indentation}Allowing breaking change to ${package.displayName} ' + 'due to --$_ignorePlatformInterfaceBreaks'); + return true; + } + + if (_getChangeDescription().contains(_breakingChangeJustificationMarker)) { + logWarning( + '${indentation}Allowing breaking change to ${package.displayName} ' + 'due to "$_breakingChangeJustificationMarker" in the change ' + 'description.'); + return true; + } + + return false; + } + + /// Returns the contents of the file pointed to by [_changeDescriptionFile], + /// or an empty string if that flag is not provided. + String _getChangeDescription() { + final String path = getStringArg(_changeDescriptionFile); + if (path.isEmpty) { + return ''; + } + final File file = packagesDir.fileSystem.file(path); + if (!file.existsSync()) { + printError('${indentation}No such file: $path'); + throw ToolExit(_exitMissingChangeDescriptionFile); + } + return file.readAsStringSync(); + } } diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 9ab7c57089a3..7d59dbb3ee7e 100644 --- a/script/tool/test/version_check_command_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -46,7 +46,7 @@ class MockProcessResult extends Mock implements io.ProcessResult {} void main() { const String indentation = ' '; group('$VersionCheckCommand', () { - FileSystem fileSystem; + late FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; late CommandRunner runner; @@ -252,7 +252,8 @@ void main() { ])); }); - test('disallows breaking changes to platform interfaces', () async { + test('disallows breaking changes to platform interfaces by default', + () async { createFakePlugin('plugin_platform_interface', packagesDir, version: '2.0.0'); gitShowResponses = { @@ -276,6 +277,92 @@ void main() { ])); }); + test('allows breaking changes to platform interfaces with explanation', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + final File changeDescriptionFile = + fileSystem.file('change_description.txt'); + changeDescriptionFile.writeAsStringSync(''' +Some general PR description + +## Breaking change justification + +This is necessary because of X, Y, and Z + +## Another section'''); + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--change-description-file=${changeDescriptionFile.path}' + ]); + + expect( + output, + containsAllInOrder([ + contains('Allowing breaking change to plugin_platform_interface ' + 'due to "## Breaking change justification" in the change ' + 'description.'), + contains('Ran for 1 package(s) (1 with warnings)'), + ]), + ); + }); + + test('throws if a nonexistent change description file is specified', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--change-description-file=a_missing_file.txt' + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No such file: a_missing_file.txt'), + ]), + ); + }); + + test('allows breaking changes to platform interfaces with bypass flag', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--ignore-platform-interface-breaks' + ]); + + expect( + output, + containsAllInOrder([ + contains('Allowing breaking change to plugin_platform_interface due ' + 'to --ignore-platform-interface-breaks'), + contains('Ran for 1 package(s) (1 with warnings)'), + ]), + ); + }); + test('Allow empty lines in front of the first version in CHANGELOG', () async { const String version = '1.0.1'; From d2c6c4314412f4d8edb5aaff8d422a9c72dac10c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 23 Sep 2021 15:40:04 -0400 Subject: [PATCH 318/364] [flutter_plugin_tools] Improve version check error handling (#4376) Catches invalid CHANGELOG formats and logs useful error messages for them, rather than throwing FormatExceptions. --- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/version_check_command.dart | 23 +++++-- .../tool/test/version_check_command_test.dart | 67 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 2bc7a901a9a2..a5263ba03965 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -8,6 +8,7 @@ - `publish-check` now validates that there is an `AUTHORS` file. - Added flags to `version-check` to allow overriding the platform interface major version change restriction. +- Improved error handling and error messages in CHANGELOG version checks. ## 0.7.1 diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index 528251fbf80d..90ba0668002b 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -374,8 +374,9 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} print( '${indentation}Found NEXT; validating next version in the CHANGELOG.'); // Ensure that the version in pubspec hasn't changed without updating - // CHANGELOG. That means the next version entry in the CHANGELOG pass the - // normal validation. + // CHANGELOG. That means the next version entry in the CHANGELOG should + // pass the normal validation. + versionString = null; while (iterator.moveNext()) { if (iterator.current.trim().startsWith('## ')) { versionString = iterator.current.trim().split(' ').last; @@ -384,11 +385,19 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} } } - final Version? fromChangeLog = - versionString == null ? null : Version.parse(versionString); - if (fromChangeLog == null) { - printError( - '${indentation}Cannot find version on the first line CHANGELOG.md'); + if (versionString == null) { + printError('${indentation}Unable to find a version in CHANGELOG.md'); + print('${indentation}The current version should be on a line starting ' + 'with "## ", either on the first non-empty line or after a "## NEXT" ' + 'section.'); + return false; + } + + final Version fromChangeLog; + try { + fromChangeLog = Version.parse(versionString); + } on FormatException { + printError('"$versionString" could not be parsed as a version.'); return false; } diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 7d59dbb3ee7e..39132212d664 100644 --- a/script/tool/test/version_check_command_test.dart +++ b/script/tool/test/version_check_command_test.dart @@ -601,6 +601,73 @@ This is necessary because of X, Y, and Z ); }); + test( + 'fails gracefully if the version headers are not found due to using the wrong style', + () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.0'); + + const String changelog = ''' +## NEXT +* Some changes for a later release. +# 1.0.0 +* Some other changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to find a version in CHANGELOG.md'), + contains('The current version should be on a line starting with ' + '"## ", either on the first non-empty line or after a "## NEXT" ' + 'section.'), + ]), + ); + }); + + test('fails gracefully if the version is unparseable', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.0'); + + const String changelog = ''' +## Alpha +* Some changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('"Alpha" could not be parsed as a version.'), + ]), + ); + }); + test('allows valid against pub', () async { mockHttpResponse = { 'name': 'some_package', From a5cd0c3aea707b03e191b1b6696bc9c14f180f9f Mon Sep 17 00:00:00 2001 From: James McIntosh Date: Fri, 24 Sep 2021 13:43:55 +1200 Subject: [PATCH 319/364] [google_maps_flutter_platfomr_interface] Add Marker drag events (#2653) This PR adds onDragStart(LatLng) and onDrag(LatLng) events to Marker objects, in addition to the already existing onDragEnd. --- .../CHANGELOG.md | 4 + .../lib/src/events/map_event.dart | 20 +++ .../method_channel_google_maps_flutter.dart | 24 +++ .../google_maps_flutter_platform.dart | 10 ++ .../lib/src/types/marker.dart | 15 +- .../pubspec.yaml | 3 +- ...thod_channel_google_maps_flutter_test.dart | 54 ++++++ .../test/types/marker_test.dart | 167 ++++++++++++++++++ 8 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md index 5d361d8e0c7c..464c33ed754e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.2 + +* Add additional marker drag events + ## 2.1.1 * Method `buildViewWithTextDirection` has been added to the platform interface. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart index be426483193d..614cbe8e29fb 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart @@ -102,6 +102,26 @@ class InfoWindowTapEvent extends MapEvent { InfoWindowTapEvent(int mapId, MarkerId markerId) : super(mapId, markerId); } +/// An event fired when a [Marker] is starting to be dragged to a new [LatLng]. +class MarkerDragStartEvent extends _PositionedMapEvent { + /// Build a MarkerDragStart Event triggered from the map represented by `mapId`. + /// + /// The `position` on this event is the [LatLng] on which the Marker was picked up from. + /// The `value` of this event is a [MarkerId] object that represents the Marker. + MarkerDragStartEvent(int mapId, LatLng position, MarkerId markerId) + : super(mapId, position, markerId); +} + +/// An event fired when a [Marker] is being dragged to a new [LatLng]. +class MarkerDragEvent extends _PositionedMapEvent { + /// Build a MarkerDrag Event triggered from the map represented by `mapId`. + /// + /// The `position` on this event is the [LatLng] on which the Marker was dragged to. + /// The `value` of this event is a [MarkerId] object that represents the Marker. + MarkerDragEvent(int mapId, LatLng position, MarkerId markerId) + : super(mapId, position, markerId); +} + /// An event fired when a [Marker] is dragged to a new [LatLng]. class MarkerDragEndEvent extends _PositionedMapEvent { /// Build a MarkerDragEnd Event triggered from the map represented by `mapId`. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart index 2b9c71ee85bd..99f4fddaccd3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart @@ -124,6 +124,16 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onMarkerDragStart({required int mapId}) { + return _events(mapId).whereType(); + } + + @override + Stream onMarkerDrag({required int mapId}) { + return _events(mapId).whereType(); + } + @override Stream onMarkerDragEnd({required int mapId}) { return _events(mapId).whereType(); @@ -174,6 +184,20 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { MarkerId(call.arguments['markerId']), )); break; + case 'marker#onDragStart': + _mapEventStreamController.add(MarkerDragStartEvent( + mapId, + LatLng.fromJson(call.arguments['position'])!, + MarkerId(call.arguments['markerId']), + )); + break; + case 'marker#onDrag': + _mapEventStreamController.add(MarkerDragEvent( + mapId, + LatLng.fromJson(call.arguments['position'])!, + MarkerId(call.arguments['markerId']), + )); + break; case 'marker#onDragEnd': _mapEventStreamController.add(MarkerDragEndEvent( mapId, diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart index 2bb0ab2588f9..08b4872ad5dd 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/platform_interface/google_maps_flutter_platform.dart @@ -303,6 +303,16 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { throw UnimplementedError('onInfoWindowTap() has not been implemented.'); } + /// A [Marker] has been dragged to a different [LatLng] position. + Stream onMarkerDragStart({required int mapId}) { + throw UnimplementedError('onMarkerDragEnd() has not been implemented.'); + } + + /// A [Marker] has been dragged to a different [LatLng] position. + Stream onMarkerDrag({required int mapId}) { + throw UnimplementedError('onMarkerDragEnd() has not been implemented.'); + } + /// A [Marker] has been dragged to a different [LatLng] position. Stream onMarkerDragEnd({required int mapId}) { throw UnimplementedError('onMarkerDragEnd() has not been implemented.'); diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart index 0d1b780c24d2..52255f84f4cc 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart @@ -147,6 +147,8 @@ class Marker implements MapsObject { this.visible = true, this.zIndex = 0.0, this.onTap, + this.onDrag, + this.onDragStart, this.onDragEnd, }) : assert(alpha == null || (0.0 <= alpha && alpha <= 1.0)); @@ -207,9 +209,15 @@ class Marker implements MapsObject { /// Callbacks to receive tap events for markers placed on this map. final VoidCallback? onTap; + /// Signature reporting the new [LatLng] at the start of a drag event. + final ValueChanged? onDragStart; + /// Signature reporting the new [LatLng] at the end of a drag event. final ValueChanged? onDragEnd; + /// Signature reporting the new [LatLng] during the drag event. + final ValueChanged? onDrag; + /// Creates a new [Marker] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Marker copyWith({ @@ -225,6 +233,8 @@ class Marker implements MapsObject { bool? visibleParam, double? zIndexParam, VoidCallback? onTapParam, + ValueChanged? onDragStartParam, + ValueChanged? onDragParam, ValueChanged? onDragEndParam, }) { return Marker( @@ -241,6 +251,8 @@ class Marker implements MapsObject { visible: visibleParam ?? visible, zIndex: zIndexParam ?? zIndex, onTap: onTapParam ?? onTap, + onDragStart: onDragStartParam ?? onDragStart, + onDrag: onDragParam ?? onDrag, onDragEnd: onDragEndParam ?? onDragEnd, ); } @@ -300,6 +312,7 @@ class Marker implements MapsObject { return 'Marker{markerId: $markerId, alpha: $alpha, anchor: $anchor, ' 'consumeTapEvents: $consumeTapEvents, draggable: $draggable, flat: $flat, ' 'icon: $icon, infoWindow: $infoWindow, position: $position, rotation: $rotation, ' - 'visible: $visible, zIndex: $zIndex, onTap: $onTap}'; + 'visible: $visible, zIndex: $zIndex, onTap: $onTap, onDragStart: $onDragStart, ' + 'onDrag: $onDrag, onDragEnd: $onDragEnd}'; } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml index 1dc73f442d2e..2a2c9cfa8b46 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.1.1 +version: 2.1.2 environment: sdk: '>=2.12.0 <3.0.0' @@ -19,6 +19,7 @@ dependencies: stream_transform: ^2.0.0 dev_dependencies: + async: ^2.5.0 flutter_test: sdk: flutter mockito: ^5.0.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart index 19e81c960839..176f702ff0ff 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart @@ -5,8 +5,12 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter_platform_interface/src/events/map_event.dart'; import 'package:google_maps_flutter_platform_interface/src/method_channel/method_channel_google_maps_flutter.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'dart:async'; + +import 'package:async/async.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -33,6 +37,15 @@ void main() { }); } + Future sendPlatformMessage( + int mapId, String method, Map data) async { + final ByteData byteData = const StandardMethodCodec() + .encodeMethodCall(MethodCall(method, data)); + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .handlePlatformMessage( + "plugins.flutter.io/google_maps_$mapId", byteData, (data) {}); + } + // Calls each method that uses invokeMethod with a return type other than // void to ensure that the casting/nullability handling succeeds. // @@ -68,5 +81,46 @@ void main() { 'map#takeSnapshot', ]); }); + test('markers send drag event to correct streams', () async { + const int mapId = 1; + final jsonMarkerDragStartEvent = { + "mapId": mapId, + "markerId": "drag-start-marker", + "position": [1.0, 1.0] + }; + final jsonMarkerDragEvent = { + "mapId": mapId, + "markerId": "drag-marker", + "position": [1.0, 1.0] + }; + final jsonMarkerDragEndEvent = { + "mapId": mapId, + "markerId": "drag-end-marker", + "position": [1.0, 1.0] + }; + + final MethodChannelGoogleMapsFlutter maps = + MethodChannelGoogleMapsFlutter(); + maps.ensureChannelInitialized(mapId); + + final StreamQueue markerDragStartStream = + StreamQueue(maps.onMarkerDragStart(mapId: mapId)); + final StreamQueue markerDragStream = + StreamQueue(maps.onMarkerDrag(mapId: mapId)); + final StreamQueue markerDragEndStream = + StreamQueue(maps.onMarkerDragEnd(mapId: mapId)); + + await sendPlatformMessage( + mapId, "marker#onDragStart", jsonMarkerDragStartEvent); + await sendPlatformMessage(mapId, "marker#onDrag", jsonMarkerDragEvent); + await sendPlatformMessage( + mapId, "marker#onDragEnd", jsonMarkerDragEndEvent); + + expect((await markerDragStartStream.next).value.value, + equals("drag-start-marker")); + expect((await markerDragStream.next).value.value, equals("drag-marker")); + expect((await markerDragEndStream.next).value.value, + equals("drag-end-marker")); + }); }); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart new file mode 100644 index 000000000000..c8f6fa527a95 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart @@ -0,0 +1,167 @@ +// Copyright 2013 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 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$Marker', () { + test('constructor defaults', () { + final Marker marker = Marker(markerId: MarkerId("ABC123")); + + expect(marker.alpha, equals(1.0)); + expect(marker.anchor, equals(const Offset(0.5, 1.0))); + expect(marker.consumeTapEvents, equals(false)); + expect(marker.draggable, equals(false)); + expect(marker.flat, equals(false)); + expect(marker.icon, equals(BitmapDescriptor.defaultMarker)); + expect(marker.infoWindow, equals(InfoWindow.noText)); + expect(marker.position, equals(const LatLng(0.0, 0.0))); + expect(marker.rotation, equals(0.0)); + expect(marker.visible, equals(true)); + expect(marker.zIndex, equals(0.0)); + expect(marker.onTap, equals(null)); + expect(marker.onDrag, equals(null)); + expect(marker.onDragStart, equals(null)); + expect(marker.onDragEnd, equals(null)); + }); + test('constructor alpha is >= 0.0 and <= 1.0', () { + final ValueSetter initWithAlpha = (double alpha) { + Marker(markerId: MarkerId("ABC123"), alpha: alpha); + }; + expect(() => initWithAlpha(-0.5), throwsAssertionError); + expect(() => initWithAlpha(0.0), isNot(throwsAssertionError)); + expect(() => initWithAlpha(0.5), isNot(throwsAssertionError)); + expect(() => initWithAlpha(1.0), isNot(throwsAssertionError)); + expect(() => initWithAlpha(100), throwsAssertionError); + }); + + test('toJson', () { + final BitmapDescriptor testDescriptor = + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan); + final Marker marker = Marker( + markerId: MarkerId("ABC123"), + alpha: 0.12345, + anchor: Offset(100, 100), + consumeTapEvents: true, + draggable: true, + flat: true, + icon: testDescriptor, + infoWindow: InfoWindow( + title: "Test title", + snippet: "Test snippet", + anchor: Offset(100, 200), + ), + position: LatLng(50, 50), + rotation: 100, + visible: false, + zIndex: 100, + onTap: () {}, + onDragStart: (LatLng latLng) {}, + onDrag: (LatLng latLng) {}, + onDragEnd: (LatLng latLng) {}, + ); + + final Map json = marker.toJson() as Map; + + expect(json, { + 'markerId': "ABC123", + 'alpha': 0.12345, + 'anchor': [100, 100], + 'consumeTapEvents': true, + 'draggable': true, + 'flat': true, + 'icon': testDescriptor.toJson(), + 'infoWindow': { + 'title': "Test title", + 'snippet': "Test snippet", + 'anchor': [100.0, 200.0], + }, + 'position': [50, 50], + 'rotation': 100.0, + 'visible': false, + 'zIndex': 100.0, + }); + }); + test('clone', () { + final Marker marker = Marker(markerId: MarkerId("ABC123")); + final Marker clone = marker.clone(); + + expect(identical(clone, marker), isFalse); + expect(clone, equals(marker)); + }); + test('copyWith', () { + final Marker marker = Marker(markerId: MarkerId("ABC123")); + + final BitmapDescriptor testDescriptor = + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan); + final double testAlphaParam = 0.12345; + final Offset testAnchorParam = Offset(100, 100); + final bool testConsumeTapEventsParam = !marker.consumeTapEvents; + final bool testDraggableParam = !marker.draggable; + final bool testFlatParam = !marker.flat; + final BitmapDescriptor testIconParam = testDescriptor; + final InfoWindow testInfoWindowParam = InfoWindow(title: "Test"); + final LatLng testPositionParam = LatLng(100, 100); + final double testRotationParam = 100; + final bool testVisibleParam = !marker.visible; + final double testZIndexParam = 100; + final List log = []; + + final copy = marker.copyWith( + alphaParam: testAlphaParam, + anchorParam: testAnchorParam, + consumeTapEventsParam: testConsumeTapEventsParam, + draggableParam: testDraggableParam, + flatParam: testFlatParam, + iconParam: testIconParam, + infoWindowParam: testInfoWindowParam, + positionParam: testPositionParam, + rotationParam: testRotationParam, + visibleParam: testVisibleParam, + zIndexParam: testZIndexParam, + onTapParam: () { + log.add("onTapParam"); + }, + onDragStartParam: (LatLng latLng) { + log.add("onDragStartParam"); + }, + onDragParam: (LatLng latLng) { + log.add("onDragParam"); + }, + onDragEndParam: (LatLng latLng) { + log.add("onDragEndParam"); + }, + ); + + expect(copy.alpha, equals(testAlphaParam)); + expect(copy.anchor, equals(testAnchorParam)); + expect(copy.consumeTapEvents, equals(testConsumeTapEventsParam)); + expect(copy.draggable, equals(testDraggableParam)); + expect(copy.flat, equals(testFlatParam)); + expect(copy.icon, equals(testIconParam)); + expect(copy.infoWindow, equals(testInfoWindowParam)); + expect(copy.position, equals(testPositionParam)); + expect(copy.rotation, equals(testRotationParam)); + expect(copy.visible, equals(testVisibleParam)); + expect(copy.zIndex, equals(testZIndexParam)); + + copy.onTap!(); + expect(log, contains("onTapParam")); + + copy.onDragStart!(LatLng(0, 1)); + expect(log, contains("onDragStartParam")); + + copy.onDrag!(LatLng(0, 1)); + expect(log, contains("onDragParam")); + + copy.onDragEnd!(LatLng(0, 1)); + expect(log, contains("onDragEndParam")); + }); + }); +} From d3c892f8261130e0927079076b43008a2ab44a5c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 23 Sep 2021 22:38:20 -0400 Subject: [PATCH 320/364] Remove some trivial custom analysis options files (#4379) Remove a few of the trivial-to-fix legacy analysis option files that were bulk added in the transition to matching flutter/flutter's analysis options. Part of flutter/flutter#76229 --- packages/cross_file/analysis_options.yaml | 1 - .../flutter_plugin_android_lifecycle/analysis_options.yaml | 1 - .../flutter_plugin_android_lifecycle_test.dart | 2 +- .../flutter_plugin_android_lifecycle/example/lib/main.dart | 2 +- .../flutter_plugin_android_lifecycle/example/pubspec.yaml | 4 ++-- packages/plugin_platform_interface/analysis_options.yaml | 1 - packages/plugin_platform_interface/pubspec.yaml | 2 +- .../test/plugin_platform_interface_test.dart | 3 +-- script/configs/custom_analysis.yaml | 3 --- 9 files changed, 6 insertions(+), 13 deletions(-) delete mode 100644 packages/cross_file/analysis_options.yaml delete mode 100644 packages/flutter_plugin_android_lifecycle/analysis_options.yaml delete mode 100644 packages/plugin_platform_interface/analysis_options.yaml diff --git a/packages/cross_file/analysis_options.yaml b/packages/cross_file/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/cross_file/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/packages/flutter_plugin_android_lifecycle/analysis_options.yaml b/packages/flutter_plugin_android_lifecycle/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/flutter_plugin_android_lifecycle/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/packages/flutter_plugin_android_lifecycle/example/integration_test/flutter_plugin_android_lifecycle_test.dart b/packages/flutter_plugin_android_lifecycle/example/integration_test/flutter_plugin_android_lifecycle_test.dart index 51f9a2537ffc..1d329a02f93b 100644 --- a/packages/flutter_plugin_android_lifecycle/example/integration_test/flutter_plugin_android_lifecycle_test.dart +++ b/packages/flutter_plugin_android_lifecycle/example/integration_test/flutter_plugin_android_lifecycle_test.dart @@ -2,9 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_plugin_android_lifecycle_example/main.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:flutter_plugin_android_lifecycle_example/main.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/packages/flutter_plugin_android_lifecycle/example/lib/main.dart b/packages/flutter_plugin_android_lifecycle/example/lib/main.dart index c019590b2a7c..3ef6794dfad2 100644 --- a/packages/flutter_plugin_android_lifecycle/example/lib/main.dart +++ b/packages/flutter_plugin_android_lifecycle/example/lib/main.dart @@ -16,7 +16,7 @@ class MyApp extends StatelessWidget { appBar: AppBar( title: const Text('Sample flutter_plugin_android_lifecycle usage'), ), - body: Center( + body: const Center( child: Text( 'This plugin only provides Android Lifecycle API\n for other Android plugins.')), ), diff --git a/packages/flutter_plugin_android_lifecycle/example/pubspec.yaml b/packages/flutter_plugin_android_lifecycle/example/pubspec.yaml index 6dc8d366e82b..0c88cd2c5531 100644 --- a/packages/flutter_plugin_android_lifecycle/example/pubspec.yaml +++ b/packages/flutter_plugin_android_lifecycle/example/pubspec.yaml @@ -17,10 +17,10 @@ dependencies: path: ../ dev_dependencies: - integration_test: - sdk: flutter flutter_test: sdk: flutter + integration_test: + sdk: flutter pedantic: ^1.8.0 flutter: diff --git a/packages/plugin_platform_interface/analysis_options.yaml b/packages/plugin_platform_interface/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/plugin_platform_interface/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/packages/plugin_platform_interface/pubspec.yaml b/packages/plugin_platform_interface/pubspec.yaml index 2980a62ee998..66527bc58a61 100644 --- a/packages/plugin_platform_interface/pubspec.yaml +++ b/packages/plugin_platform_interface/pubspec.yaml @@ -24,5 +24,5 @@ dependencies: dev_dependencies: mockito: ^5.0.0 - test: ^1.16.0 pedantic: ^1.10.0 + test: ^1.16.0 diff --git a/packages/plugin_platform_interface/test/plugin_platform_interface_test.dart b/packages/plugin_platform_interface/test/plugin_platform_interface_test.dart index 0079765f0b09..967fa79d6dc3 100644 --- a/packages/plugin_platform_interface/test/plugin_platform_interface_test.dart +++ b/packages/plugin_platform_interface/test/plugin_platform_interface_test.dart @@ -3,9 +3,8 @@ // found in the LICENSE file. import 'package:mockito/mockito.dart'; -import 'package:test/test.dart'; - import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:test/test.dart'; class SamplePluginPlatform extends PlatformInterface { SamplePluginPlatform() : super(token: _token); diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml index 2b0f844de7e0..2e214757d90d 100644 --- a/script/configs/custom_analysis.yaml +++ b/script/configs/custom_analysis.yaml @@ -17,15 +17,12 @@ # https://github.com/flutter/flutter/issues/76229 - camera - file_selector -- flutter_plugin_android_lifecycle - google_maps_flutter - google_sign_in - image_picker - in_app_purchase -- integration_test - ios_platform_images - local_auth -- plugin_platform_interface - quick_actions - shared_preferences - url_launcher From 0c282812b194ebf0f8ead790245dc4ed64026b39 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 24 Sep 2021 13:55:44 -0400 Subject: [PATCH 321/364] [webview_flutter] Adjust integration test domains (#4383) Tests that load https://www.google.com and then assert that that's the current URL are failing, because Google is redirecting our tests to a "suspicious activity" page. Switch to example.com, as a domain that is less likely to do traffic-based redirection. --- .../integration_test/webview_flutter_test.dart | 16 ++++++++-------- .../integration_test/webview_flutter_test.dart | 16 ++++++++-------- .../integration_test/webview_flutter_test.dart | 16 ++++++++-------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index a6211b2dae75..896366798b18 100644 --- a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -58,9 +58,9 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.loadUrl('https://www.google.com/'); + await controller.loadUrl('https://www.example.com/'); final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -1100,11 +1100,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; await controller - .evaluateJavascript('location.href = "https://www.google.com/"'); + .evaluateJavascript('location.href = "https://www.example.com/"'); await pageLoads.stream.first; // Wait for the next page load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }); testWidgets('onWebResourceError', (WidgetTester tester) async { @@ -1272,11 +1272,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; await controller - .evaluateJavascript('location.href = "https://www.google.com"'); + .evaluateJavascript('location.href = "https://www.example.com"'); await pageLoads.stream.first; // Wait for second page to load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }); }); @@ -1365,10 +1365,10 @@ void main() { pageLoaded = Completer(); await controller - .evaluateJavascript('window.open("https://www.google.com/")'); + .evaluateJavascript('window.open("https://www.example.com/")'); await pageLoaded.future; pageLoaded = Completer(); - expect(controller.currentUrl(), completion('https://www.google.com/')); + expect(controller.currentUrl(), completion('https://www.example.com/')); expect(controller.canGoBack(), completion(true)); await controller.goBack(); diff --git a/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart index e218d908729c..3dab048c8ff2 100644 --- a/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart @@ -64,9 +64,9 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.loadUrl('https://www.google.com/'); + await controller.loadUrl('https://www.example.com/'); final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -1054,11 +1054,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; await controller - .evaluateJavascript('location.href = "https://www.google.com/"'); + .evaluateJavascript('location.href = "https://www.example.com/"'); await pageLoads.stream.first; // Wait for the next page load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }); testWidgets('onWebResourceError', (WidgetTester tester) async { @@ -1221,11 +1221,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; await controller - .evaluateJavascript('location.href = "https://www.google.com"'); + .evaluateJavascript('location.href = "https://www.example.com"'); await pageLoads.stream.first; // Wait for second page to load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }); }); @@ -1314,10 +1314,10 @@ void main() { pageLoaded = Completer(); await controller - .evaluateJavascript('window.open("https://www.google.com/")'); + .evaluateJavascript('window.open("https://www.example.com/")'); await pageLoaded.future; pageLoaded = Completer(); - expect(controller.currentUrl(), completion('https://www.google.com/')); + expect(controller.currentUrl(), completion('https://www.example.com/')); expect(controller.canGoBack(), completion(true)); await controller.goBack(); diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart index 33d5b340fa5a..c080c46120ec 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart @@ -61,9 +61,9 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.loadUrl('https://www.google.com/'); + await controller.loadUrl('https://www.example.com/'); final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }, skip: _skipDueToIssue86757); testWidgets('loadUrl with headers', (WidgetTester tester) async { @@ -913,11 +913,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; await controller - .evaluateJavascript('location.href = "https://www.google.com/"'); + .evaluateJavascript('location.href = "https://www.example.com/"'); await pageLoads.stream.first; // Wait for the next page load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }); testWidgets('onWebResourceError', (WidgetTester tester) async { @@ -1085,11 +1085,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; await controller - .evaluateJavascript('location.href = "https://www.google.com"'); + .evaluateJavascript('location.href = "https://www.example.com"'); await pageLoads.stream.first; // Wait for second page to load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.google.com/'); + expect(currentUrl, 'https://www.example.com/'); }); }); @@ -1176,10 +1176,10 @@ void main() { pageLoaded = Completer(); await controller - .evaluateJavascript('window.open("https://www.google.com/")'); + .evaluateJavascript('window.open("https://www.example.com/")'); await pageLoaded.future; pageLoaded = Completer(); - expect(controller.currentUrl(), completion('https://www.google.com/')); + expect(controller.currentUrl(), completion('https://www.example.com/')); expect(controller.canGoBack(), completion(true)); await controller.goBack(); From cc3c53cde5abe3f9c122984a14d10a5ff13b5ccc Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Fri, 24 Sep 2021 19:58:06 +0200 Subject: [PATCH 322/364] [webview_flutter] Update version number app_facing package (#4375) --- packages/webview_flutter/webview_flutter/CHANGELOG.md | 4 ++++ packages/webview_flutter/webview_flutter/pubspec.yaml | 2 +- script/configs/exclude_all_plugins_app.yaml | 6 ------ 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 99b8a5c419ca..92ac557d01af 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 + +* Migrated to fully federated architecture. + ## 2.0.14 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index 985f3178916f..ab870639d615 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.14 +version: 2.1.0 environment: sdk: ">=2.14.0 <3.0.0" diff --git a/script/configs/exclude_all_plugins_app.yaml b/script/configs/exclude_all_plugins_app.yaml index f6246aae2c86..8dd0fde5ef5f 100644 --- a/script/configs/exclude_all_plugins_app.yaml +++ b/script/configs/exclude_all_plugins_app.yaml @@ -8,9 +8,3 @@ # This is a permament entry, as it should never be a direct app dependency. - plugin_platform_interface -# TODO(mvanbeusekom): Remove the exclusion of the webview_flutter_android and -# webview_flutter_wkwebview packages once the native -# implementation is removed from the webview_flutter -# package (see https://github.com/flutter/flutter/issues/86286). -- webview_flutter_android -- webview_flutter_wkwebview From 5ec61962da5e6a9e0f899d19fa66c1fa9233691a Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Mon, 27 Sep 2021 11:50:49 -0700 Subject: [PATCH 323/364] [in_app_purchase] Bump dependencies on json_serializable, build_runner (#4386) Regenerate output Also put in a pointer to the json_serializable issue related to creating stand-alone enum converters Co-authored-by: Chris Yang --- .../in_app_purchase_android/CHANGELOG.md | 4 + .../enum_converters.dart | 1 + .../enum_converters.g.dart | 21 ++- .../purchase_wrapper.g.dart | 109 +++++++-------- .../sku_details_wrapper.g.dart | 78 +++++------ .../in_app_purchase_android/pubspec.yaml | 4 +- .../in_app_purchase_ios/CHANGELOG.md | 4 + .../store_kit_wrappers/enum_converters.dart | 1 + .../store_kit_wrappers/enum_converters.g.dart | 16 +-- .../sk_payment_queue_wrapper.g.dart | 36 +++-- .../sk_payment_transaction_wrappers.g.dart | 33 +++-- .../sk_product_wrapper.g.dart | 129 +++++++++--------- .../sk_storefront_wrapper.g.dart | 11 +- .../in_app_purchase_ios/pubspec.yaml | 4 +- 14 files changed, 218 insertions(+), 233 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 7a998d0547de..10c77cb6b4d3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Update dev_dependency `build_runner` to ^2.0.0 and `json_serializable` to ^5.0.2. + ## 0.1.4+7 * Ensure that the `SkuDetailsWrapper.introductoryPriceMicros` is populated correctly. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart index 7ff333098fcc..931d92f7b1e7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart @@ -132,6 +132,7 @@ class BillingClientFeatureConverter } // Define a class so we generate serializer helper methods for the enums +// See https://github.com/google/json_serializable.dart/issues/778 @JsonSerializable() class _SerializedEnums { late BillingResponse response; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart index 8d667d035196..fe92f56653e4 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart @@ -6,19 +6,16 @@ part of 'enum_converters.dart'; // JsonSerializableGenerator // ************************************************************************** -_SerializedEnums _$_SerializedEnumsFromJson(Map json) { - return _SerializedEnums() - ..response = _$enumDecode(_$BillingResponseEnumMap, json['response']) - ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']) - ..purchaseState = - _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']) - ..prorationMode = - _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']) - ..billingClientFeature = _$enumDecode( - _$BillingClientFeatureEnumMap, json['billingClientFeature']); -} +_SerializedEnums _$SerializedEnumsFromJson(Map json) => _SerializedEnums() + ..response = _$enumDecode(_$BillingResponseEnumMap, json['response']) + ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']) + ..purchaseState = + _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']) + ..prorationMode = _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']) + ..billingClientFeature = + _$enumDecode(_$BillingClientFeatureEnumMap, json['billingClientFeature']); -Map _$_SerializedEnumsToJson(_SerializedEnums instance) => +Map _$SerializedEnumsToJson(_SerializedEnums instance) => { 'response': _$BillingResponseEnumMap[instance.response], 'type': _$SkuTypeEnumMap[instance.type], diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart index 5607dbdd8cb2..b5d9fe8cd3af 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart @@ -6,24 +6,22 @@ part of 'purchase_wrapper.dart'; // JsonSerializableGenerator // ************************************************************************** -PurchaseWrapper _$PurchaseWrapperFromJson(Map json) { - return PurchaseWrapper( - orderId: json['orderId'] as String? ?? '', - packageName: json['packageName'] as String? ?? '', - purchaseTime: json['purchaseTime'] as int? ?? 0, - purchaseToken: json['purchaseToken'] as String? ?? '', - signature: json['signature'] as String? ?? '', - sku: json['sku'] as String? ?? '', - isAutoRenewing: json['isAutoRenewing'] as bool, - originalJson: json['originalJson'] as String? ?? '', - developerPayload: json['developerPayload'] as String?, - isAcknowledged: json['isAcknowledged'] as bool? ?? false, - purchaseState: - const PurchaseStateConverter().fromJson(json['purchaseState'] as int?), - obfuscatedAccountId: json['obfuscatedAccountId'] as String?, - obfuscatedProfileId: json['obfuscatedProfileId'] as String?, - ); -} +PurchaseWrapper _$PurchaseWrapperFromJson(Map json) => PurchaseWrapper( + orderId: json['orderId'] as String? ?? '', + packageName: json['packageName'] as String? ?? '', + purchaseTime: json['purchaseTime'] as int? ?? 0, + purchaseToken: json['purchaseToken'] as String? ?? '', + signature: json['signature'] as String? ?? '', + sku: json['sku'] as String? ?? '', + isAutoRenewing: json['isAutoRenewing'] as bool, + originalJson: json['originalJson'] as String? ?? '', + developerPayload: json['developerPayload'] as String?, + isAcknowledged: json['isAcknowledged'] as bool? ?? false, + purchaseState: const PurchaseStateConverter() + .fromJson(json['purchaseState'] as int?), + obfuscatedAccountId: json['obfuscatedAccountId'] as String?, + obfuscatedProfileId: json['obfuscatedProfileId'] as String?, + ); Map _$PurchaseWrapperToJson(PurchaseWrapper instance) => { @@ -43,16 +41,15 @@ Map _$PurchaseWrapperToJson(PurchaseWrapper instance) => 'obfuscatedProfileId': instance.obfuscatedProfileId, }; -PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) { - return PurchaseHistoryRecordWrapper( - purchaseTime: json['purchaseTime'] as int? ?? 0, - purchaseToken: json['purchaseToken'] as String? ?? '', - signature: json['signature'] as String? ?? '', - sku: json['sku'] as String? ?? '', - originalJson: json['originalJson'] as String? ?? '', - developerPayload: json['developerPayload'] as String?, - ); -} +PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) => + PurchaseHistoryRecordWrapper( + purchaseTime: json['purchaseTime'] as int? ?? 0, + purchaseToken: json['purchaseToken'] as String? ?? '', + signature: json['signature'] as String? ?? '', + sku: json['sku'] as String? ?? '', + originalJson: json['originalJson'] as String? ?? '', + developerPayload: json['developerPayload'] as String?, + ); Map _$PurchaseHistoryRecordWrapperToJson( PurchaseHistoryRecordWrapper instance) => @@ -65,21 +62,20 @@ Map _$PurchaseHistoryRecordWrapperToJson( 'developerPayload': instance.developerPayload, }; -PurchasesResultWrapper _$PurchasesResultWrapperFromJson(Map json) { - return PurchasesResultWrapper( - responseCode: - const BillingResponseConverter().fromJson(json['responseCode'] as int?), - billingResult: - BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - purchasesList: (json['purchasesList'] as List?) - ?.map((e) => - PurchaseWrapper.fromJson(Map.from(e as Map))) - .toList() ?? - [], - ); -} +PurchasesResultWrapper _$PurchasesResultWrapperFromJson(Map json) => + PurchasesResultWrapper( + responseCode: const BillingResponseConverter() + .fromJson(json['responseCode'] as int?), + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + purchasesList: (json['purchasesList'] as List?) + ?.map((e) => + PurchaseWrapper.fromJson(Map.from(e as Map))) + .toList() ?? + [], + ); Map _$PurchasesResultWrapperToJson( PurchasesResultWrapper instance) => @@ -90,20 +86,19 @@ Map _$PurchasesResultWrapperToJson( 'purchasesList': instance.purchasesList, }; -PurchasesHistoryResult _$PurchasesHistoryResultFromJson(Map json) { - return PurchasesHistoryResult( - billingResult: - BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - purchaseHistoryRecordList: - (json['purchaseHistoryRecordList'] as List?) - ?.map((e) => PurchaseHistoryRecordWrapper.fromJson( - Map.from(e as Map))) - .toList() ?? - [], - ); -} +PurchasesHistoryResult _$PurchasesHistoryResultFromJson(Map json) => + PurchasesHistoryResult( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + purchaseHistoryRecordList: + (json['purchaseHistoryRecordList'] as List?) + ?.map((e) => PurchaseHistoryRecordWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); Map _$PurchasesHistoryResultToJson( PurchasesHistoryResult instance) => diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart index d21f832a2de6..1fc450ed6933 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -6,27 +6,25 @@ part of 'sku_details_wrapper.dart'; // JsonSerializableGenerator // ************************************************************************** -SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { - return SkuDetailsWrapper( - description: json['description'] as String? ?? '', - freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', - introductoryPrice: json['introductoryPrice'] as String? ?? '', - introductoryPriceMicros: - json['introductoryPriceAmountMicros'] as String? ?? '', - introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, - introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', - price: json['price'] as String? ?? '', - priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, - priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', - priceCurrencySymbol: json['priceCurrencySymbol'] as String? ?? '', - sku: json['sku'] as String? ?? '', - subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', - title: json['title'] as String? ?? '', - type: const SkuTypeConverter().fromJson(json['type'] as String?), - originalPrice: json['originalPrice'] as String? ?? '', - originalPriceAmountMicros: json['originalPriceAmountMicros'] as int? ?? 0, - ); -} +SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) => SkuDetailsWrapper( + description: json['description'] as String? ?? '', + freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', + introductoryPrice: json['introductoryPrice'] as String? ?? '', + introductoryPriceMicros: + json['introductoryPriceAmountMicros'] as String? ?? '', + introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, + introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', + price: json['price'] as String? ?? '', + priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, + priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', + priceCurrencySymbol: json['priceCurrencySymbol'] as String? ?? '', + sku: json['sku'] as String? ?? '', + subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', + title: json['title'] as String? ?? '', + type: const SkuTypeConverter().fromJson(json['type'] as String?), + originalPrice: json['originalPrice'] as String? ?? '', + originalPriceAmountMicros: json['originalPriceAmountMicros'] as int? ?? 0, + ); Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => { @@ -48,19 +46,18 @@ Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => 'originalPriceAmountMicros': instance.originalPriceAmountMicros, }; -SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) { - return SkuDetailsResponseWrapper( - billingResult: - BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - skuDetailsList: (json['skuDetailsList'] as List?) - ?.map((e) => - SkuDetailsWrapper.fromJson(Map.from(e as Map))) - .toList() ?? - [], - ); -} +SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) => + SkuDetailsResponseWrapper( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + skuDetailsList: (json['skuDetailsList'] as List?) + ?.map((e) => SkuDetailsWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); Map _$SkuDetailsResponseWrapperToJson( SkuDetailsResponseWrapper instance) => @@ -69,13 +66,12 @@ Map _$SkuDetailsResponseWrapperToJson( 'skuDetailsList': instance.skuDetailsList, }; -BillingResultWrapper _$BillingResultWrapperFromJson(Map json) { - return BillingResultWrapper( - responseCode: - const BillingResponseConverter().fromJson(json['responseCode'] as int?), - debugMessage: json['debugMessage'] as String?, - ); -} +BillingResultWrapper _$BillingResultWrapperFromJson(Map json) => + BillingResultWrapper( + responseCode: const BillingResponseConverter() + .fromJson(json['responseCode'] as int?), + debugMessage: json['debugMessage'] as String?, + ); Map _$BillingResultWrapperToJson( BillingResultWrapper instance) => diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 64a40889f375..3a60abbae19d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -25,8 +25,8 @@ dependencies: meta: ^1.3.0 dev_dependencies: - build_runner: ^1.11.1 + build_runner: ^2.0.0 flutter_test: sdk: flutter - json_serializable: ^4.1.1 + json_serializable: ^5.0.2 test: ^1.16.0 diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 04509e56ecde..7e3902b206ab 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Update dev_dependency `build_runner` to ^2.0.0 and `json_serializable` to ^5.0.2. + ## 0.1.3+4 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart index 08af2c6058c4..70178260febf 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart @@ -101,6 +101,7 @@ class SKProductDiscountPaymentModeConverter } // Define a class so we generate serializer helper methods for the enums +// See https://github.com/google/json_serializable.dart/issues/778 @JsonSerializable() class _SerializedEnums { late SKPaymentTransactionStateWrapper response; diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart index b003f435a800..ce0f56ba4d34 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.g.dart @@ -6,16 +6,14 @@ part of 'enum_converters.dart'; // JsonSerializableGenerator // ************************************************************************** -_SerializedEnums _$_SerializedEnumsFromJson(Map json) { - return _SerializedEnums() - ..response = _$enumDecode( - _$SKPaymentTransactionStateWrapperEnumMap, json['response']) - ..unit = _$enumDecode(_$SKSubscriptionPeriodUnitEnumMap, json['unit']) - ..discountPaymentMode = _$enumDecode( - _$SKProductDiscountPaymentModeEnumMap, json['discountPaymentMode']); -} +_SerializedEnums _$SerializedEnumsFromJson(Map json) => _SerializedEnums() + ..response = + _$enumDecode(_$SKPaymentTransactionStateWrapperEnumMap, json['response']) + ..unit = _$enumDecode(_$SKSubscriptionPeriodUnitEnumMap, json['unit']) + ..discountPaymentMode = _$enumDecode( + _$SKProductDiscountPaymentModeEnumMap, json['discountPaymentMode']); -Map _$_SerializedEnumsToJson(_SerializedEnums instance) => +Map _$SerializedEnumsToJson(_SerializedEnums instance) => { 'response': _$SKPaymentTransactionStateWrapperEnumMap[instance.response], 'unit': _$SKSubscriptionPeriodUnitEnumMap[instance.unit], diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart index 2b886597adc5..4d2b5e4b3d4b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_wrapper.g.dart @@ -6,16 +6,14 @@ part of 'sk_payment_queue_wrapper.dart'; // JsonSerializableGenerator // ************************************************************************** -SKError _$SKErrorFromJson(Map json) { - return SKError( - code: json['code'] as int? ?? 0, - domain: json['domain'] as String? ?? '', - userInfo: (json['userInfo'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - ) ?? - {}, - ); -} +SKError _$SKErrorFromJson(Map json) => SKError( + code: json['code'] as int? ?? 0, + domain: json['domain'] as String? ?? '', + userInfo: (json['userInfo'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ) ?? + {}, + ); Map _$SKErrorToJson(SKError instance) => { 'code': instance.code, @@ -23,16 +21,14 @@ Map _$SKErrorToJson(SKError instance) => { 'userInfo': instance.userInfo, }; -SKPaymentWrapper _$SKPaymentWrapperFromJson(Map json) { - return SKPaymentWrapper( - productIdentifier: json['productIdentifier'] as String? ?? '', - applicationUsername: json['applicationUsername'] as String?, - requestData: json['requestData'] as String?, - quantity: json['quantity'] as int? ?? 0, - simulatesAskToBuyInSandbox: - json['simulatesAskToBuyInSandbox'] as bool? ?? false, - ); -} +SKPaymentWrapper _$SKPaymentWrapperFromJson(Map json) => SKPaymentWrapper( + productIdentifier: json['productIdentifier'] as String? ?? '', + applicationUsername: json['applicationUsername'] as String?, + requestData: json['requestData'] as String?, + quantity: json['quantity'] as int? ?? 0, + simulatesAskToBuyInSandbox: + json['simulatesAskToBuyInSandbox'] as bool? ?? false, + ); Map _$SKPaymentWrapperToJson(SKPaymentWrapper instance) => { diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart index 4c7af21bc151..fd10d9ad977b 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart @@ -6,23 +6,22 @@ part of 'sk_payment_transaction_wrappers.dart'; // JsonSerializableGenerator // ************************************************************************** -SKPaymentTransactionWrapper _$SKPaymentTransactionWrapperFromJson(Map json) { - return SKPaymentTransactionWrapper( - payment: SKPaymentWrapper.fromJson( - Map.from(json['payment'] as Map)), - transactionState: const SKTransactionStatusConverter() - .fromJson(json['transactionState'] as int?), - originalTransaction: json['originalTransaction'] == null - ? null - : SKPaymentTransactionWrapper.fromJson( - Map.from(json['originalTransaction'] as Map)), - transactionTimeStamp: (json['transactionTimeStamp'] as num?)?.toDouble(), - transactionIdentifier: json['transactionIdentifier'] as String?, - error: json['error'] == null - ? null - : SKError.fromJson(Map.from(json['error'] as Map)), - ); -} +SKPaymentTransactionWrapper _$SKPaymentTransactionWrapperFromJson(Map json) => + SKPaymentTransactionWrapper( + payment: SKPaymentWrapper.fromJson( + Map.from(json['payment'] as Map)), + transactionState: const SKTransactionStatusConverter() + .fromJson(json['transactionState'] as int?), + originalTransaction: json['originalTransaction'] == null + ? null + : SKPaymentTransactionWrapper.fromJson( + Map.from(json['originalTransaction'] as Map)), + transactionTimeStamp: (json['transactionTimeStamp'] as num?)?.toDouble(), + transactionIdentifier: json['transactionIdentifier'] as String?, + error: json['error'] == null + ? null + : SKError.fromJson(Map.from(json['error'] as Map)), + ); Map _$SKPaymentTransactionWrapperToJson( SKPaymentTransactionWrapper instance) => diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart index 66f4b7827c38..485bf1932efa 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart @@ -6,20 +6,19 @@ part of 'sk_product_wrapper.dart'; // JsonSerializableGenerator // ************************************************************************** -SkProductResponseWrapper _$SkProductResponseWrapperFromJson(Map json) { - return SkProductResponseWrapper( - products: (json['products'] as List?) - ?.map((e) => - SKProductWrapper.fromJson(Map.from(e as Map))) - .toList() ?? - [], - invalidProductIdentifiers: - (json['invalidProductIdentifiers'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], - ); -} +SkProductResponseWrapper _$SkProductResponseWrapperFromJson(Map json) => + SkProductResponseWrapper( + products: (json['products'] as List?) + ?.map((e) => SKProductWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + invalidProductIdentifiers: + (json['invalidProductIdentifiers'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); Map _$SkProductResponseWrapperToJson( SkProductResponseWrapper instance) => @@ -29,13 +28,12 @@ Map _$SkProductResponseWrapperToJson( }; SKProductSubscriptionPeriodWrapper _$SKProductSubscriptionPeriodWrapperFromJson( - Map json) { - return SKProductSubscriptionPeriodWrapper( - numberOfUnits: json['numberOfUnits'] as int? ?? 0, - unit: const SKSubscriptionPeriodUnitConverter() - .fromJson(json['unit'] as int?), - ); -} + Map json) => + SKProductSubscriptionPeriodWrapper( + numberOfUnits: json['numberOfUnits'] as int? ?? 0, + unit: const SKSubscriptionPeriodUnitConverter() + .fromJson(json['unit'] as int?), + ); Map _$SKProductSubscriptionPeriodWrapperToJson( SKProductSubscriptionPeriodWrapper instance) => @@ -44,22 +42,21 @@ Map _$SKProductSubscriptionPeriodWrapperToJson( 'unit': const SKSubscriptionPeriodUnitConverter().toJson(instance.unit), }; -SKProductDiscountWrapper _$SKProductDiscountWrapperFromJson(Map json) { - return SKProductDiscountWrapper( - price: json['price'] as String? ?? '', - priceLocale: - SKPriceLocaleWrapper.fromJson((json['priceLocale'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - numberOfPeriods: json['numberOfPeriods'] as int? ?? 0, - paymentMode: const SKProductDiscountPaymentModeConverter() - .fromJson(json['paymentMode'] as int?), - subscriptionPeriod: SKProductSubscriptionPeriodWrapper.fromJson( - (json['subscriptionPeriod'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - ); -} +SKProductDiscountWrapper _$SKProductDiscountWrapperFromJson(Map json) => + SKProductDiscountWrapper( + price: json['price'] as String? ?? '', + priceLocale: + SKPriceLocaleWrapper.fromJson((json['priceLocale'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + numberOfPeriods: json['numberOfPeriods'] as int? ?? 0, + paymentMode: const SKProductDiscountPaymentModeConverter() + .fromJson(json['paymentMode'] as int?), + subscriptionPeriod: SKProductSubscriptionPeriodWrapper.fromJson( + (json['subscriptionPeriod'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + ); Map _$SKProductDiscountWrapperToJson( SKProductDiscountWrapper instance) => @@ -72,29 +69,28 @@ Map _$SKProductDiscountWrapperToJson( 'subscriptionPeriod': instance.subscriptionPeriod, }; -SKProductWrapper _$SKProductWrapperFromJson(Map json) { - return SKProductWrapper( - productIdentifier: json['productIdentifier'] as String? ?? '', - localizedTitle: json['localizedTitle'] as String? ?? '', - localizedDescription: json['localizedDescription'] as String? ?? '', - priceLocale: - SKPriceLocaleWrapper.fromJson((json['priceLocale'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - subscriptionGroupIdentifier: json['subscriptionGroupIdentifier'] as String?, - price: json['price'] as String? ?? '', - subscriptionPeriod: json['subscriptionPeriod'] == null - ? null - : SKProductSubscriptionPeriodWrapper.fromJson( - (json['subscriptionPeriod'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - introductoryPrice: json['introductoryPrice'] == null - ? null - : SKProductDiscountWrapper.fromJson( - Map.from(json['introductoryPrice'] as Map)), - ); -} +SKProductWrapper _$SKProductWrapperFromJson(Map json) => SKProductWrapper( + productIdentifier: json['productIdentifier'] as String? ?? '', + localizedTitle: json['localizedTitle'] as String? ?? '', + localizedDescription: json['localizedDescription'] as String? ?? '', + priceLocale: + SKPriceLocaleWrapper.fromJson((json['priceLocale'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + subscriptionGroupIdentifier: + json['subscriptionGroupIdentifier'] as String?, + price: json['price'] as String? ?? '', + subscriptionPeriod: json['subscriptionPeriod'] == null + ? null + : SKProductSubscriptionPeriodWrapper.fromJson( + (json['subscriptionPeriod'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + introductoryPrice: json['introductoryPrice'] == null + ? null + : SKProductDiscountWrapper.fromJson( + Map.from(json['introductoryPrice'] as Map)), + ); Map _$SKProductWrapperToJson(SKProductWrapper instance) => { @@ -108,13 +104,12 @@ Map _$SKProductWrapperToJson(SKProductWrapper instance) => 'introductoryPrice': instance.introductoryPrice, }; -SKPriceLocaleWrapper _$SKPriceLocaleWrapperFromJson(Map json) { - return SKPriceLocaleWrapper( - currencySymbol: json['currencySymbol'] as String? ?? '', - currencyCode: json['currencyCode'] as String? ?? '', - countryCode: json['countryCode'] as String? ?? '', - ); -} +SKPriceLocaleWrapper _$SKPriceLocaleWrapperFromJson(Map json) => + SKPriceLocaleWrapper( + currencySymbol: json['currencySymbol'] as String? ?? '', + currencyCode: json['currencyCode'] as String? ?? '', + countryCode: json['countryCode'] as String? ?? '', + ); Map _$SKPriceLocaleWrapperToJson( SKPriceLocaleWrapper instance) => diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart index f75cfc5711e8..b2d5d3a06d1d 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart @@ -6,12 +6,11 @@ part of 'sk_storefront_wrapper.dart'; // JsonSerializableGenerator // ************************************************************************** -SKStorefrontWrapper _$SKStorefrontWrapperFromJson(Map json) { - return SKStorefrontWrapper( - countryCode: json['countryCode'] as String, - identifier: json['identifier'] as String, - ); -} +SKStorefrontWrapper _$SKStorefrontWrapperFromJson(Map json) => + SKStorefrontWrapper( + countryCode: json['countryCode'] as String, + identifier: json['identifier'] as String, + ); Map _$SKStorefrontWrapperToJson( SKStorefrontWrapper instance) => diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 9ba642e2e590..30a57bb56c94 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -24,8 +24,8 @@ dependencies: meta: ^1.3.0 dev_dependencies: - build_runner: ^1.11.1 + build_runner: ^2.0.0 flutter_test: sdk: flutter - json_serializable: ^4.1.1 + json_serializable: ^5.0.2 test: ^1.16.0 From 80b5d2ed3f3107180e4e4032a7633a3cf3fcf05f Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Mon, 27 Sep 2021 23:30:09 +0200 Subject: [PATCH 324/364] [ci] Temporary run publish task on Flutter stable channel. (#4388) --- .cirrus.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.cirrus.yml b/.cirrus.yml index 67343ee15f88..ef6b9c1b6d44 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -69,6 +69,9 @@ task: - dart pub run test - name: publishable env: + # TODO (mvanbeusekom): Temporary override to "stable" because of failure on "master". + # Remove override once https://github.com/dart-lang/pub/issues/3152 is resolved. + CHANNEL: stable CHANGE_DESC: "$TMPDIR/change-description.txt" version_check_script: # For pre-submit, pass the PR description to the script to allow for From f58ab59880d76f625df07a2cd48f176729aec834 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 28 Sep 2021 00:38:04 +0200 Subject: [PATCH 325/364] Load navigation controls immediately. (#4378) --- .../webview_flutter_wkwebview/CHANGELOG.md | 4 ++++ .../webview_flutter_wkwebview/example/lib/main.dart | 8 ++++---- .../webview_flutter_wkwebview/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md b/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md index 1a85bc8a53e5..242d79b4bd82 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.14 + +* Update example App so navigation menu loads immediatly but only becomes available when `WebViewController` is available (same behavior as example App in webview_flutter package). + ## 2.0.13 * Extract WKWebView implementation from `webview_flutter`. diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart index a953e062ded5..15b4cfc7c549 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart @@ -289,7 +289,7 @@ class _NavigationControls extends StatelessWidget { final bool webViewReady = snapshot.connectionState == ConnectionState.done; final WebViewController? controller = snapshot.data; - if (controller == null) return Container(); + return Row( children: [ IconButton( @@ -297,7 +297,7 @@ class _NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () async { - if (await controller.canGoBack()) { + if (await controller!.canGoBack()) { await controller.goBack(); } else { // ignore: deprecated_member_use @@ -313,7 +313,7 @@ class _NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () async { - if (await controller.canGoForward()) { + if (await controller!.canGoForward()) { await controller.goForward(); } else { // ignore: deprecated_member_use @@ -330,7 +330,7 @@ class _NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () { - controller.reload(); + controller!.reload(); }, ), ], diff --git a/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml b/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml index c6f6d6f94f07..a7305cea7a94 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter_wkwebview description: A Flutter plugin that provides a WebView widget based on Apple's WKWebView control. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_wkwebview issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.13 +version: 2.0.14 environment: sdk: ">=2.14.0 <3.0.0" From 8a71e0ee47c770f29977f6c3810867cb4d1e4376 Mon Sep 17 00:00:00 2001 From: BeMacized Date: Tue, 28 Sep 2021 09:08:06 +0200 Subject: [PATCH 326/364] [camera] Fix IllegalStateException being thrown in Android implementation when switching activities. (#4319) --- packages/camera/camera/CHANGELOG.md | 4 ++ packages/camera/camera/android/build.gradle | 2 +- .../io/flutter/plugins/camera/Camera.java | 51 +++++++++++++++---- .../flutter/plugins/camera/CameraPlugin.java | 18 ++----- .../plugins/camera/MethodCallHandlerImpl.java | 17 +------ .../io/flutter/plugins/camera/CameraTest.java | 43 ++++++++++++++++ .../camera/MethodCallHandlerImplTest.java | 12 ++++- packages/camera/camera/pubspec.yaml | 2 +- 8 files changed, 107 insertions(+), 42 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 62b5f1f9bd4c..b2dda9a52436 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.4+1 + +* Fixed Android implementation throwing IllegalStateException when switching to a different activity. + ## 0.9.4 * Add web support by endorsing `package:camera_web`. diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle index 61d13e5579cc..633efd0b284a 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -60,7 +60,7 @@ android { dependencies { compileOnly 'androidx.annotation:annotation:1.1.0' testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-inline:3.11.1' + testImplementation 'org.mockito:mockito-inline:3.12.4' testImplementation 'androidx.test:core:1.3.0' testImplementation 'org.robolectric:robolectric:4.3' } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index 4601e7d34d69..75ced531b08a 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -35,9 +35,7 @@ import android.view.Surface; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleObserver; -import androidx.lifecycle.OnLifecycleEvent; +import androidx.annotation.VisibleForTesting; import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; @@ -82,8 +80,7 @@ interface ErrorCallback { class Camera implements CameraCaptureCallback.CameraCaptureStateListener, - ImageReader.OnImageAvailableListener, - LifecycleObserver { + ImageReader.OnImageAvailableListener { private static final String TAG = "Camera"; private static final HashMap supportedImageFormats; @@ -576,19 +573,21 @@ private Display getDefaultDisplay() { } /** Starts a background thread and its {@link Handler}. */ - @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) public void startBackgroundThread() { - backgroundHandlerThread = new HandlerThread("CameraBackground"); + if (backgroundHandlerThread != null) { + return; + } + + backgroundHandlerThread = HandlerThreadFactory.create("CameraBackground"); try { backgroundHandlerThread.start(); } catch (IllegalThreadStateException e) { // Ignore exception in case the thread has already started. } - backgroundHandler = new Handler(backgroundHandlerThread.getLooper()); + backgroundHandler = HandlerFactory.create(backgroundHandlerThread.getLooper()); } /** Stops the background thread and its {@link Handler}. */ - @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) public void stopBackgroundThread() { if (backgroundHandlerThread != null) { backgroundHandlerThread.quitSafely(); @@ -1120,4 +1119,38 @@ public void dispose() { flutterTexture.release(); getDeviceOrientationManager().stop(); } + + /** Factory class that assists in creating a {@link HandlerThread} instance. */ + static class HandlerThreadFactory { + /** + * Creates a new instance of the {@link HandlerThread} class. + * + *

    This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param name to give to the HandlerThread. + * @return new instance of the {@link HandlerThread} class. + */ + @VisibleForTesting + public static HandlerThread create(String name) { + return new HandlerThread(name); + } + } + + /** Factory class that assists in creating a {@link Handler} instance. */ + static class HandlerFactory { + /** + * Creates a new instance of the {@link Handler} class. + * + *

    This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param looper to give to the Handler. + * @return new instance of the {@link Handler} class. + */ + @VisibleForTesting + public static Handler create(Looper looper) { + return new Handler(looper); + } + } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java index ef3a2b9b5d83..067ed0295e2e 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java @@ -8,11 +8,9 @@ import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; -import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugins.camera.CameraPermissions.PermissionsRegistry; import io.flutter.view.TextureRegistry; @@ -53,8 +51,7 @@ public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registra registrar.activity(), registrar.messenger(), registrar::addRequestPermissionsResultListener, - registrar.view(), - null); + registrar.view()); } @Override @@ -73,8 +70,7 @@ public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { binding.getActivity(), flutterPluginBinding.getBinaryMessenger(), binding::addRequestPermissionsResultListener, - flutterPluginBinding.getTextureRegistry(), - FlutterLifecycleAdapter.getActivityLifecycle(binding)); + flutterPluginBinding.getTextureRegistry()); } @Override @@ -100,8 +96,7 @@ private void maybeStartListening( Activity activity, BinaryMessenger messenger, PermissionsRegistry permissionsRegistry, - TextureRegistry textureRegistry, - @Nullable Lifecycle lifecycle) { + TextureRegistry textureRegistry) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { // If the sdk is less than 21 (min sdk for Camera2) we don't register the plugin. return; @@ -109,11 +104,6 @@ private void maybeStartListening( methodCallHandler = new MethodCallHandlerImpl( - activity, - messenger, - new CameraPermissions(), - permissionsRegistry, - textureRegistry, - lifecycle); + activity, messenger, new CameraPermissions(), permissionsRegistry, textureRegistry); } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index 5e25353cbca9..35cc2b081bae 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -10,8 +10,6 @@ import android.os.Looper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleObserver; import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.EventChannel; @@ -29,7 +27,7 @@ import java.util.HashMap; import java.util.Map; -final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler, LifecycleObserver { +final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler { private final Activity activity; private final BinaryMessenger messenger; private final CameraPermissions cameraPermissions; @@ -37,7 +35,6 @@ final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler, Li private final TextureRegistry textureRegistry; private final MethodChannel methodChannel; private final EventChannel imageStreamChannel; - private final Lifecycle lifecycle; private @Nullable Camera camera; MethodCallHandlerImpl( @@ -45,14 +42,12 @@ final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler, Li BinaryMessenger messenger, CameraPermissions cameraPermissions, PermissionsRegistry permissionsAdder, - TextureRegistry textureRegistry, - @Nullable Lifecycle lifecycle) { + TextureRegistry textureRegistry) { this.activity = activity; this.messenger = messenger; this.cameraPermissions = cameraPermissions; this.permissionsRegistry = permissionsAdder; this.textureRegistry = textureRegistry; - this.lifecycle = lifecycle; methodChannel = new MethodChannel(messenger, "plugins.flutter.io/camera"); imageStreamChannel = new EventChannel(messenger, "plugins.flutter.io/camera/imageStream"); @@ -387,10 +382,6 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce new CameraPropertiesImpl(cameraName, CameraUtils.getCameraManager(activity)); ResolutionPreset resolutionPreset = ResolutionPreset.valueOf(preset); - if (camera != null && lifecycle != null) { - lifecycle.removeObserver(camera); - } - camera = new Camera( activity, @@ -401,10 +392,6 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce resolutionPreset, enableAudio); - if (lifecycle != null) { - lifecycle.addObserver(camera); - } - Map reply = new HashMap<>(); reply.put("cameraId", flutterSurfaceTexture.id()); result.success(reply); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java index fbed28bc11fc..9d973195435e 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -5,11 +5,13 @@ package io.flutter.plugins.camera; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -23,7 +25,10 @@ import android.media.CamcorderProfile; import android.media.MediaRecorder; import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; import androidx.annotation.NonNull; +import androidx.lifecycle.LifecycleObserver; import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugins.camera.features.CameraFeatureFactory; @@ -49,6 +54,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedStatic; public class CameraTest { private CameraProperties mockCameraProperties; @@ -57,6 +63,10 @@ public class CameraTest { private Camera camera; private CameraCaptureSession mockCaptureSession; private CaptureRequest.Builder mockPreviewRequestBuilder; + private MockedStatic mockHandlerThreadFactory; + private HandlerThread mockHandlerThread; + private MockedStatic mockHandlerFactory; + private Handler mockHandler; @Before public void before() { @@ -65,6 +75,10 @@ public void before() { mockDartMessenger = mock(DartMessenger.class); mockCaptureSession = mock(CameraCaptureSession.class); mockPreviewRequestBuilder = mock(CaptureRequest.Builder.class); + mockHandlerThreadFactory = mockStatic(Camera.HandlerThreadFactory.class); + mockHandlerThread = mock(HandlerThread.class); + mockHandlerFactory = mockStatic(Camera.HandlerFactory.class); + mockHandler = mock(Handler.class); final Activity mockActivity = mock(Activity.class); final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = @@ -74,6 +88,10 @@ public void before() { final boolean enableAudio = false; when(mockCameraProperties.getCameraName()).thenReturn(cameraName); + mockHandlerFactory.when(() -> Camera.HandlerFactory.create(any())).thenReturn(mockHandler); + mockHandlerThreadFactory + .when(() -> Camera.HandlerThreadFactory.create(any())) + .thenReturn(mockHandlerThread); camera = new Camera( @@ -92,6 +110,15 @@ public void before() { @After public void after() { TestUtils.setFinalStatic(Build.VERSION.class, "SDK_INT", 0); + mockHandlerThreadFactory.close(); + mockHandlerFactory.close(); + } + + @Test + public void shouldNotImplementLifecycleObserverInterface() { + Class cameraClass = Camera.class; + + assertFalse(LifecycleObserver.class.isAssignableFrom(cameraClass)); } @Test @@ -773,6 +800,22 @@ public void resumePreview_shouldSendErrorEventOnCameraAccessException() verify(mockDartMessenger, times(1)).sendCameraErrorEvent(any()); } + @Test + public void startBackgroundThread_shouldStartNewThread() { + camera.startBackgroundThread(); + + verify(mockHandlerThread, times(1)).start(); + assertEquals(mockHandler, TestUtils.getPrivateField(camera, "backgroundHandler")); + } + + @Test + public void startBackgroundThread_shouldNotStartNewThreadWhenAlreadyCreated() { + camera.startBackgroundThread(); + camera.startBackgroundThread(); + + verify(mockHandlerThread, times(1)).start(); + } + private static class TestCameraFeatureFactory implements CameraFeatureFactory { private final AutoFocusFeature mockAutoFocusFeature; private final ExposureLockFeature mockExposureLockFeature; diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java index 35eed7a66a1a..868e2e9e6d57 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java @@ -4,6 +4,7 @@ package io.flutter.plugins.camera; +import static org.junit.Assert.assertFalse; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -11,6 +12,7 @@ import android.app.Activity; import android.hardware.camera2.CameraAccessException; +import androidx.lifecycle.LifecycleObserver; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -33,13 +35,19 @@ public void setUp() { mock(BinaryMessenger.class), mock(CameraPermissions.class), mock(CameraPermissions.PermissionsRegistry.class), - mock(TextureRegistry.class), - null); + mock(TextureRegistry.class)); mockResult = mock(MethodChannel.Result.class); mockCamera = mock(Camera.class); TestUtils.setPrivateField(handler, "camera", mockCamera); } + @Test + public void shouldNotImplementLifecycleObserverInterface() { + Class methodCallHandlerClass = MethodCallHandlerImpl.class; + + assertFalse(LifecycleObserver.class.isAssignableFrom(methodCallHandlerClass)); + } + @Test public void onMethodCall_pausePreview_shouldPausePreviewAndSendSuccessResult() throws CameraAccessException { diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index b8894d58ac3a..5c225eaee48f 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for getting information about and controlling the and streaming image buffers to dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.4 +version: 0.9.4+1 environment: sdk: ">=2.14.0 <3.0.0" From 326e3c4f2765184978038eb55e5cf2785c9d690d Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 28 Sep 2021 09:13:04 +0200 Subject: [PATCH 327/364] Load navigation controls immediately. (#4377) --- .../webview_flutter/webview_flutter_android/CHANGELOG.md | 4 ++++ .../webview_flutter_android/example/lib/main.dart | 8 ++++---- .../webview_flutter/webview_flutter_android/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/webview_flutter/webview_flutter_android/CHANGELOG.md b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md index d6a10e9b918a..917a3c73acb1 100644 --- a/packages/webview_flutter/webview_flutter_android/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.14 + +* Update example App so navigation menu loads immediatly but only becomes available when `WebViewController` is available (same behavior as example App in webview_flutter package). + ## 2.0.13 * Extract Android implementation from `webview_flutter`. diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/main.dart b/packages/webview_flutter/webview_flutter_android/example/lib/main.dart index 6176ce255eb9..65f49716aaac 100644 --- a/packages/webview_flutter/webview_flutter_android/example/lib/main.dart +++ b/packages/webview_flutter/webview_flutter_android/example/lib/main.dart @@ -289,7 +289,7 @@ class _NavigationControls extends StatelessWidget { final bool webViewReady = snapshot.connectionState == ConnectionState.done; final WebViewController? controller = snapshot.data; - if (controller == null) return Container(); + return Row( children: [ IconButton( @@ -297,7 +297,7 @@ class _NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () async { - if (await controller.canGoBack()) { + if (await controller!.canGoBack()) { await controller.goBack(); } else { // ignore: deprecated_member_use @@ -313,7 +313,7 @@ class _NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () async { - if (await controller.canGoForward()) { + if (await controller!.canGoForward()) { await controller.goForward(); } else { // ignore: deprecated_member_use @@ -330,7 +330,7 @@ class _NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () { - controller.reload(); + controller!.reload(); }, ), ], diff --git a/packages/webview_flutter/webview_flutter_android/pubspec.yaml b/packages/webview_flutter/webview_flutter_android/pubspec.yaml index f7db4c6fb63a..4a3d4cb1d942 100644 --- a/packages/webview_flutter/webview_flutter_android/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter_android/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter_android description: A Flutter plugin that provides a WebView widget on Android. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.13 +version: 2.0.14 environment: sdk: ">=2.14.0 <3.0.0" From f48f8dba59fb624154ca19834f31dbbc3ac0769a Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 28 Sep 2021 09:23:05 +0200 Subject: [PATCH 328/364] [in_app_purchase] Ensure the introductoryPriceMicros field is transported as a String. (#4370) --- .../in_app_purchase_android/CHANGELOG.md | 3 +- .../sku_details_wrapper.dart | 28 ++++++-- .../sku_details_wrapper.g.dart | 6 +- .../in_app_purchase_android/pubspec.yaml | 2 +- .../sku_details_wrapper_deprecated_test.dart | 69 +++++++++++++++++++ .../sku_details_wrapper_test.dart | 4 +- 6 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 10c77cb6b4d3..0c75ae3cf819 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.1.5 +* Introduced the `SkuDetailsWrapper.introductoryPriceAmountMicros` field of the correct type (`int`) and deprecated the `SkuDetailsWrapper.introductoryPriceMicros` field. * Update dev_dependency `build_runner` to ^2.0.0 and `json_serializable` to ^5.0.2. ## 0.1.4+7 diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart index 9c349badbb04..754f7a352f1c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -32,7 +32,9 @@ class SkuDetailsWrapper { required this.description, required this.freeTrialPeriod, required this.introductoryPrice, - required this.introductoryPriceMicros, + @Deprecated('Use `introductoryPriceAmountMicros` parameter instead') + String introductoryPriceMicros = '', + this.introductoryPriceAmountMicros = 0, required this.introductoryPriceCycles, required this.introductoryPricePeriod, required this.price, @@ -45,7 +47,9 @@ class SkuDetailsWrapper { required this.type, required this.originalPrice, required this.originalPriceAmountMicros, - }); + }) : _introductoryPriceMicros = introductoryPriceMicros; + + final String _introductoryPriceMicros; /// Constructs an instance of this from a key value map of data. /// @@ -67,9 +71,19 @@ class SkuDetailsWrapper { @JsonKey(defaultValue: '') final String introductoryPrice; - /// [introductoryPrice] in micro-units 990000 - @JsonKey(name: 'introductoryPriceAmountMicros', defaultValue: '') - final String introductoryPriceMicros; + /// [introductoryPrice] in micro-units 990000. + /// + /// Returns 0 if the SKU is not a subscription or doesn't have an introductory + /// period. + @JsonKey(name: 'introductoryPriceAmountMicros', defaultValue: 0) + final int introductoryPriceAmountMicros; + + /// String representation of [introductoryPrice] in micro-units 990000 + @Deprecated('Use `introductoryPriceAmountMicros` instead.') + @JsonKey(ignore: true) + String get introductoryPriceMicros => _introductoryPriceMicros.isEmpty + ? introductoryPriceAmountMicros.toString() + : _introductoryPriceMicros; /// The number of subscription billing periods for which the user will be given the introductory price, such as 3. /// Returns 0 if the SKU is not a subscription or doesn't have an introductory period. @@ -131,7 +145,7 @@ class SkuDetailsWrapper { other.description == description && other.freeTrialPeriod == freeTrialPeriod && other.introductoryPrice == introductoryPrice && - other.introductoryPriceMicros == introductoryPriceMicros && + other.introductoryPriceAmountMicros == introductoryPriceAmountMicros && other.introductoryPriceCycles == introductoryPriceCycles && other.introductoryPricePeriod == introductoryPricePeriod && other.price == price && @@ -150,7 +164,7 @@ class SkuDetailsWrapper { description.hashCode, freeTrialPeriod.hashCode, introductoryPrice.hashCode, - introductoryPriceMicros.hashCode, + introductoryPriceAmountMicros.hashCode, introductoryPriceCycles.hashCode, introductoryPricePeriod.hashCode, price.hashCode, diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart index 1fc450ed6933..53d5931ecb56 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -10,8 +10,8 @@ SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) => SkuDetailsWrapper( description: json['description'] as String? ?? '', freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', introductoryPrice: json['introductoryPrice'] as String? ?? '', - introductoryPriceMicros: - json['introductoryPriceAmountMicros'] as String? ?? '', + introductoryPriceAmountMicros: + json['introductoryPriceAmountMicros'] as int? ?? 0, introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', price: json['price'] as String? ?? '', @@ -31,7 +31,7 @@ Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => 'description': instance.description, 'freeTrialPeriod': instance.freeTrialPeriod, 'introductoryPrice': instance.introductoryPrice, - 'introductoryPriceAmountMicros': instance.introductoryPriceMicros, + 'introductoryPriceAmountMicros': instance.introductoryPriceAmountMicros, 'introductoryPriceCycles': instance.introductoryPriceCycles, 'introductoryPricePeriod': instance.introductoryPricePeriod, 'price': instance.price, diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 3a60abbae19d..33f51cc70feb 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4+7 +version: 0.1.5 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart new file mode 100644 index 000000000000..3e29d92724ad --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart @@ -0,0 +1,69 @@ +// Copyright 2013 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. + +// TODO(mvanbeusekom): Remove this file when the deprecated +// `SkuDetailsWrapper.introductoryPriceMicros` field is +// removed. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; + +void main() { + test( + 'Deprecated `introductoryPriceMicros` field reflects parameter from constructor', + () { + final SkuDetailsWrapper skuDetails = SkuDetailsWrapper( + description: 'description', + freeTrialPeriod: 'freeTrialPeriod', + introductoryPrice: 'introductoryPrice', + // ignore: deprecated_member_use_from_same_package + introductoryPriceMicros: '990000', + introductoryPriceCycles: 1, + introductoryPricePeriod: 'introductoryPricePeriod', + price: 'price', + priceAmountMicros: 1000, + priceCurrencyCode: 'priceCurrencyCode', + priceCurrencySymbol: r'$', + sku: 'sku', + subscriptionPeriod: 'subscriptionPeriod', + title: 'title', + type: SkuType.inapp, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, + ); + + expect(skuDetails, isNotNull); + expect(skuDetails.introductoryPriceAmountMicros, 0); + // ignore: deprecated_member_use_from_same_package + expect(skuDetails.introductoryPriceMicros, '990000'); + }); + + test( + '`introductoryPriceAmoutMicros` constructor parameter is reflected by deprecated `introductoryPriceMicros` and `introductoryPriceAmountMicros` fields', + () { + final SkuDetailsWrapper skuDetails = SkuDetailsWrapper( + description: 'description', + freeTrialPeriod: 'freeTrialPeriod', + introductoryPrice: 'introductoryPrice', + introductoryPriceAmountMicros: 990000, + introductoryPriceCycles: 1, + introductoryPricePeriod: 'introductoryPricePeriod', + price: 'price', + priceAmountMicros: 1000, + priceCurrencyCode: 'priceCurrencyCode', + priceCurrencySymbol: r'$', + sku: 'sku', + subscriptionPeriod: 'subscriptionPeriod', + title: 'title', + type: SkuType.inapp, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, + ); + + expect(skuDetails, isNotNull); + expect(skuDetails.introductoryPriceAmountMicros, 990000); + // ignore: deprecated_member_use_from_same_package + expect(skuDetails.introductoryPriceMicros, '990000'); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart index 62d9104f3738..18804a41940e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -11,7 +11,7 @@ final SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( description: 'description', freeTrialPeriod: 'freeTrialPeriod', introductoryPrice: 'introductoryPrice', - introductoryPriceMicros: 'introductoryPriceMicros', + introductoryPriceAmountMicros: 990000, introductoryPriceCycles: 1, introductoryPricePeriod: 'introductoryPricePeriod', price: 'price', @@ -134,7 +134,7 @@ Map buildSkuMap(SkuDetailsWrapper original) { 'description': original.description, 'freeTrialPeriod': original.freeTrialPeriod, 'introductoryPrice': original.introductoryPrice, - 'introductoryPriceAmountMicros': original.introductoryPriceMicros, + 'introductoryPriceAmountMicros': original.introductoryPriceAmountMicros, 'introductoryPriceCycles': original.introductoryPriceCycles, 'introductoryPricePeriod': original.introductoryPricePeriod, 'price': original.price, From 44706929299d759ada6864d2a3ab9fe45398ab08 Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Tue, 28 Sep 2021 11:23:02 +0200 Subject: [PATCH 329/364] Fixed _CastError when running example App (#4390) --- packages/webview_flutter/webview_flutter/CHANGELOG.md | 4 ++++ .../webview_flutter/webview_flutter/example/lib/main.dart | 8 ++++---- packages/webview_flutter/webview_flutter/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 92ac557d01af..6724b43476ff 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.1 + +* Fixed `_CastError` that was thrown when running the example App. + ## 2.1.0 * Migrated to fully federated architecture. diff --git a/packages/webview_flutter/webview_flutter/example/lib/main.dart b/packages/webview_flutter/webview_flutter/example/lib/main.dart index 88256cc66287..c456a9691455 100644 --- a/packages/webview_flutter/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/webview_flutter/example/lib/main.dart @@ -302,7 +302,7 @@ class NavigationControls extends StatelessWidget { (BuildContext context, AsyncSnapshot snapshot) { final bool webViewReady = snapshot.connectionState == ConnectionState.done; - final WebViewController controller = snapshot.data!; + final WebViewController? controller = snapshot.data; return Row( children: [ IconButton( @@ -310,7 +310,7 @@ class NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () async { - if (await controller.canGoBack()) { + if (await controller!.canGoBack()) { await controller.goBack(); } else { // ignore: deprecated_member_use @@ -326,7 +326,7 @@ class NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () async { - if (await controller.canGoForward()) { + if (await controller!.canGoForward()) { await controller.goForward(); } else { // ignore: deprecated_member_use @@ -343,7 +343,7 @@ class NavigationControls extends StatelessWidget { onPressed: !webViewReady ? null : () { - controller.reload(); + controller!.reload(); }, ), ], diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index ab870639d615..4206789cb1b3 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.1.0 +version: 2.1.1 environment: sdk: ">=2.14.0 <3.0.0" From f63395d86dbb5acc7488824fc5d81055bdebc041 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 28 Sep 2021 09:20:20 -0400 Subject: [PATCH 330/364] [flutter_plugin_tools] Check licenses in Kotlin (#4373) The license check overlooked Kotlin, since it's not currently widely used in our repositories. Also adds the missing license to one Kotlin file, from an example that was (likely accidentally) re-generated using Kotlin instead of Java. --- .../main/kotlin/io/flutter/plugins/example/MainActivity.kt | 4 ++++ script/tool/CHANGELOG.md | 1 + script/tool/lib/src/license_check_command.dart | 1 + script/tool/test/license_check_command_test.dart | 1 + 4 files changed, 7 insertions(+) diff --git a/packages/shared_preferences/shared_preferences/example/android/app/src/main/kotlin/io/flutter/plugins/example/MainActivity.kt b/packages/shared_preferences/shared_preferences/example/android/app/src/main/kotlin/io/flutter/plugins/example/MainActivity.kt index 9059dae9e4c4..50cad6f36e24 100644 --- a/packages/shared_preferences/shared_preferences/example/android/app/src/main/kotlin/io/flutter/plugins/example/MainActivity.kt +++ b/packages/shared_preferences/shared_preferences/example/android/app/src/main/kotlin/io/flutter/plugins/example/MainActivity.kt @@ -1,3 +1,7 @@ +// Copyright 2013 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. + package io.flutter.plugins.example import io.flutter.embedding.android.FlutterActivity diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index a5263ba03965..6119545260aa 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -9,6 +9,7 @@ - Added flags to `version-check` to allow overriding the platform interface major version change restriction. - Improved error handling and error messages in CHANGELOG version checks. +- `license-check` now validates Kotlin files. ## 0.7.1 diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index 8cee46b45a4c..7165e985c059 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -16,6 +16,7 @@ const Set _codeFileExtensions = { '.h', '.html', '.java', + '.kt', '.m', '.mm', '.swift', diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart index 288cf4696a59..5a8a90e9a674 100644 --- a/script/tool/test/license_check_command_test.dart +++ b/script/tool/test/license_check_command_test.dart @@ -66,6 +66,7 @@ void main() { 'html': true, 'java': true, 'json': false, + 'kt': true, 'm': true, 'md': false, 'mm': true, From 1ef44050141b8792050bfbde6cab0916f558f254 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Tue, 28 Sep 2021 15:45:08 -0400 Subject: [PATCH 331/364] [file_selector] Remove custom analysis options (#4382) Switches to the new repo-standard analysis options, and fixes all violations. Part of https://github.com/flutter/flutter/issues/76229 --- packages/file_selector/analysis_options.yaml | 1 - .../file_selector/file_selector/CHANGELOG.md | 4 + .../example/lib/get_directory_page.dart | 22 ++-- .../file_selector/example/lib/home_page.dart | 20 +-- .../file_selector/example/lib/main.dart | 18 +-- .../example/lib/open_image_page.dart | 26 ++-- .../lib/open_multiple_images_page.dart | 31 ++--- .../example/lib/open_text_page.dart | 23 ++-- .../example/lib/save_text_page.dart | 16 +-- .../file_selector/example/pubspec.yaml | 5 +- .../test/file_selector_test.dart | 76 ++++++------ .../CHANGELOG.md | 4 + .../method_channel_file_selector.dart | 20 +-- .../src/types/x_type_group/x_type_group.dart | 7 +- .../lib/src/web_helpers/web_helpers.dart | 4 +- ...file_selector_platform_interface_test.dart | 3 +- .../method_channel_file_selector_test.dart | 117 ++++++++++-------- .../test/x_type_group_test.dart | 26 ++-- .../file_selector_web/CHANGELOG.md | 4 + .../integration_test/dom_helper_test.dart | 25 ++-- .../file_selector_web_test.dart | 51 ++++---- .../file_selector_web/example/lib/main.dart | 2 +- .../lib/file_selector_web.dart | 22 ++-- .../file_selector_web/lib/src/dom_helper.dart | 17 +-- .../file_selector_web/lib/src/utils.dart | 8 +- .../file_selector_web/test/utils_test.dart | 45 +++---- script/configs/custom_analysis.yaml | 1 - 27 files changed, 321 insertions(+), 277 deletions(-) delete mode 100644 packages/file_selector/analysis_options.yaml diff --git a/packages/file_selector/analysis_options.yaml b/packages/file_selector/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/file_selector/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/packages/file_selector/file_selector/CHANGELOG.md b/packages/file_selector/file_selector/CHANGELOG.md index 2f8a4d0754f1..225f601c3ee6 100644 --- a/packages/file_selector/file_selector/CHANGELOG.md +++ b/packages/file_selector/file_selector/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Minor code cleanup for new analysis rules. + ## 0.8.2 * Update platform_plugin_interface version requirement. diff --git a/packages/file_selector/file_selector/example/lib/get_directory_page.dart b/packages/file_selector/file_selector/example/lib/get_directory_page.dart index 6022559ce40e..b3ed9d0eeaca 100644 --- a/packages/file_selector/file_selector/example/lib/get_directory_page.dart +++ b/packages/file_selector/file_selector/example/lib/get_directory_page.dart @@ -7,8 +7,8 @@ import 'package:flutter/material.dart'; /// Screen that shows an example of getDirectoryPath class GetDirectoryPage extends StatelessWidget { - void _getDirectoryPath(BuildContext context) async { - final String confirmButtonText = 'Choose'; + Future _getDirectoryPath(BuildContext context) async { + const String confirmButtonText = 'Choose'; final String? directoryPath = await getDirectoryPath( confirmButtonText: confirmButtonText, ); @@ -16,9 +16,9 @@ class GetDirectoryPage extends StatelessWidget { // Operation was canceled by the user. return; } - await showDialog( + await showDialog( context: context, - builder: (context) => TextDisplay(directoryPath), + builder: (BuildContext context) => TextDisplay(directoryPath), ); } @@ -26,7 +26,7 @@ class GetDirectoryPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("Open a text file"), + title: const Text('Open a text file'), ), body: Center( child: Column( @@ -37,7 +37,7 @@ class GetDirectoryPage extends StatelessWidget { primary: Colors.blue, onPrimary: Colors.white, ), - child: Text('Press to ask user to choose a directory'), + child: const Text('Press to ask user to choose a directory'), onPressed: () => _getDirectoryPath(context), ), ], @@ -49,22 +49,22 @@ class GetDirectoryPage extends StatelessWidget { /// Widget that displays a text file in a dialog class TextDisplay extends StatelessWidget { + /// Default Constructor + const TextDisplay(this.directoryPath); + /// Directory path final String directoryPath; - /// Default Constructor - TextDisplay(this.directoryPath); - @override Widget build(BuildContext context) { return AlertDialog( - title: Text('Selected Directory'), + title: const Text('Selected Directory'), content: Scrollbar( child: SingleChildScrollView( child: Text(directoryPath), ), ), - actions: [ + actions: [ TextButton( child: const Text('Close'), onPressed: () => Navigator.pop(context), diff --git a/packages/file_selector/file_selector/example/lib/home_page.dart b/packages/file_selector/file_selector/example/lib/home_page.dart index ab0b5c32187c..c598cbdf2611 100644 --- a/packages/file_selector/file_selector/example/lib/home_page.dart +++ b/packages/file_selector/file_selector/example/lib/home_page.dart @@ -14,7 +14,7 @@ class HomePage extends StatelessWidget { ); return Scaffold( appBar: AppBar( - title: Text('File Selector Demo Home Page'), + title: const Text('File Selector Demo Home Page'), ), body: Center( child: Column( @@ -22,31 +22,31 @@ class HomePage extends StatelessWidget { children: [ ElevatedButton( style: style, - child: Text('Open a text file'), + child: const Text('Open a text file'), onPressed: () => Navigator.pushNamed(context, '/open/text'), ), - SizedBox(height: 10), + const SizedBox(height: 10), ElevatedButton( style: style, - child: Text('Open an image'), + child: const Text('Open an image'), onPressed: () => Navigator.pushNamed(context, '/open/image'), ), - SizedBox(height: 10), + const SizedBox(height: 10), ElevatedButton( style: style, - child: Text('Open multiple images'), + child: const Text('Open multiple images'), onPressed: () => Navigator.pushNamed(context, '/open/images'), ), - SizedBox(height: 10), + const SizedBox(height: 10), ElevatedButton( style: style, - child: Text('Save a file'), + child: const Text('Save a file'), onPressed: () => Navigator.pushNamed(context, '/save/text'), ), - SizedBox(height: 10), + const SizedBox(height: 10), ElevatedButton( style: style, - child: Text('Open a get directory dialog'), + child: const Text('Open a get directory dialog'), onPressed: () => Navigator.pushNamed(context, '/directory'), ), ], diff --git a/packages/file_selector/file_selector/example/lib/main.dart b/packages/file_selector/file_selector/example/lib/main.dart index bd4c9b7ab853..14ce3f593f33 100644 --- a/packages/file_selector/file_selector/example/lib/main.dart +++ b/packages/file_selector/file_selector/example/lib/main.dart @@ -2,13 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; -import 'package:example/home_page.dart'; import 'package:example/get_directory_page.dart'; -import 'package:example/open_text_page.dart'; +import 'package:example/home_page.dart'; import 'package:example/open_image_page.dart'; import 'package:example/open_multiple_images_page.dart'; +import 'package:example/open_text_page.dart'; import 'package:example/save_text_page.dart'; +import 'package:flutter/material.dart'; void main() { runApp(MyApp()); @@ -25,12 +25,12 @@ class MyApp extends StatelessWidget { visualDensity: VisualDensity.adaptivePlatformDensity, ), home: HomePage(), - routes: { - '/open/image': (context) => OpenImagePage(), - '/open/images': (context) => OpenMultipleImagesPage(), - '/open/text': (context) => OpenTextPage(), - '/save/text': (context) => SaveTextPage(), - '/directory': (context) => GetDirectoryPage(), + routes: { + '/open/image': (BuildContext context) => OpenImagePage(), + '/open/images': (BuildContext context) => OpenMultipleImagesPage(), + '/open/text': (BuildContext context) => OpenTextPage(), + '/save/text': (BuildContext context) => SaveTextPage(), + '/directory': (BuildContext context) => GetDirectoryPage(), }, ); } diff --git a/packages/file_selector/file_selector/example/lib/open_image_page.dart b/packages/file_selector/file_selector/example/lib/open_image_page.dart index ae0d5ad4eefb..0abdba6eb72d 100644 --- a/packages/file_selector/file_selector/example/lib/open_image_page.dart +++ b/packages/file_selector/file_selector/example/lib/open_image_page.dart @@ -3,18 +3,20 @@ // found in the LICENSE file. import 'dart:io'; -import 'package:flutter/foundation.dart'; + import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Screen that shows an example of openFiles class OpenImagePage extends StatelessWidget { - void _openImageFile(BuildContext context) async { + Future _openImageFile(BuildContext context) async { final XTypeGroup typeGroup = XTypeGroup( label: 'images', - extensions: ['jpg', 'png'], + extensions: ['jpg', 'png'], ); - final List files = await openFiles(acceptedTypeGroups: [typeGroup]); + final List files = + await openFiles(acceptedTypeGroups: [typeGroup]); if (files.isEmpty) { // Operation was canceled by the user. return; @@ -23,9 +25,9 @@ class OpenImagePage extends StatelessWidget { final String fileName = file.name; final String filePath = file.path; - await showDialog( + await showDialog( context: context, - builder: (context) => ImageDisplay(fileName, filePath), + builder: (BuildContext context) => ImageDisplay(fileName, filePath), ); } @@ -33,7 +35,7 @@ class OpenImagePage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("Open an image"), + title: const Text('Open an image'), ), body: Center( child: Column( @@ -44,7 +46,7 @@ class OpenImagePage extends StatelessWidget { primary: Colors.blue, onPrimary: Colors.white, ), - child: Text('Press to open an image file(png, jpg)'), + child: const Text('Press to open an image file(png, jpg)'), onPressed: () => _openImageFile(context), ), ], @@ -56,15 +58,15 @@ class OpenImagePage extends StatelessWidget { /// Widget that displays a text file in a dialog class ImageDisplay extends StatelessWidget { + /// Default Constructor + const ImageDisplay(this.fileName, this.filePath); + /// Image's name final String fileName; /// Image's path final String filePath; - /// Default Constructor - ImageDisplay(this.fileName, this.filePath); - @override Widget build(BuildContext context) { return AlertDialog( @@ -72,7 +74,7 @@ class ImageDisplay extends StatelessWidget { // On web the filePath is a blob url // while on other platforms it is a system path. content: kIsWeb ? Image.network(filePath) : Image.file(File(filePath)), - actions: [ + actions: [ TextButton( child: const Text('Close'), onPressed: () { diff --git a/packages/file_selector/file_selector/example/lib/open_multiple_images_page.dart b/packages/file_selector/file_selector/example/lib/open_multiple_images_page.dart index 5bf080eff450..9a1101214aaa 100644 --- a/packages/file_selector/file_selector/example/lib/open_multiple_images_page.dart +++ b/packages/file_selector/file_selector/example/lib/open_multiple_images_page.dart @@ -3,22 +3,23 @@ // found in the LICENSE file. import 'dart:io'; -import 'package:flutter/foundation.dart'; + import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Screen that shows an example of openFiles class OpenMultipleImagesPage extends StatelessWidget { - void _openImageFile(BuildContext context) async { + Future _openImageFile(BuildContext context) async { final XTypeGroup jpgsTypeGroup = XTypeGroup( label: 'JPEGs', - extensions: ['jpg', 'jpeg'], + extensions: ['jpg', 'jpeg'], ); final XTypeGroup pngTypeGroup = XTypeGroup( label: 'PNGs', - extensions: ['png'], + extensions: ['png'], ); - final List files = await openFiles(acceptedTypeGroups: [ + final List files = await openFiles(acceptedTypeGroups: [ jpgsTypeGroup, pngTypeGroup, ]); @@ -26,9 +27,9 @@ class OpenMultipleImagesPage extends StatelessWidget { // Operation was canceled by the user. return; } - await showDialog( + await showDialog( context: context, - builder: (context) => MultipleImagesDisplay(files), + builder: (BuildContext context) => MultipleImagesDisplay(files), ); } @@ -36,7 +37,7 @@ class OpenMultipleImagesPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("Open multiple images"), + title: const Text('Open multiple images'), ), body: Center( child: Column( @@ -47,7 +48,7 @@ class OpenMultipleImagesPage extends StatelessWidget { primary: Colors.blue, onPrimary: Colors.white, ), - child: Text('Press to open multiple images (png, jpg)'), + child: const Text('Press to open multiple images (png, jpg)'), onPressed: () => _openImageFile(context), ), ], @@ -59,23 +60,23 @@ class OpenMultipleImagesPage extends StatelessWidget { /// Widget that displays a text file in a dialog class MultipleImagesDisplay extends StatelessWidget { + /// Default Constructor + const MultipleImagesDisplay(this.files); + /// The files containing the images final List files; - /// Default Constructor - MultipleImagesDisplay(this.files); - @override Widget build(BuildContext context) { return AlertDialog( - title: Text('Gallery'), + title: const Text('Gallery'), // On web the filePath is a blob url // while on other platforms it is a system path. content: Center( child: Row( children: [ ...files.map( - (file) => Flexible( + (XFile file) => Flexible( child: kIsWeb ? Image.network(file.path) : Image.file(File(file.path))), @@ -83,7 +84,7 @@ class MultipleImagesDisplay extends StatelessWidget { ], ), ), - actions: [ + actions: [ TextButton( child: const Text('Close'), onPressed: () { diff --git a/packages/file_selector/file_selector/example/lib/open_text_page.dart b/packages/file_selector/file_selector/example/lib/open_text_page.dart index 8451378f7baa..652e8596cf81 100644 --- a/packages/file_selector/file_selector/example/lib/open_text_page.dart +++ b/packages/file_selector/file_selector/example/lib/open_text_page.dart @@ -7,12 +7,13 @@ import 'package:flutter/material.dart'; /// Screen that shows an example of openFile class OpenTextPage extends StatelessWidget { - void _openTextFile(BuildContext context) async { + Future _openTextFile(BuildContext context) async { final XTypeGroup typeGroup = XTypeGroup( label: 'text', - extensions: ['txt', 'json'], + extensions: ['txt', 'json'], ); - final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]); + final XFile? file = + await openFile(acceptedTypeGroups: [typeGroup]); if (file == null) { // Operation was canceled by the user. return; @@ -20,9 +21,9 @@ class OpenTextPage extends StatelessWidget { final String fileName = file.name; final String fileContent = await file.readAsString(); - await showDialog( + await showDialog( context: context, - builder: (context) => TextDisplay(fileName, fileContent), + builder: (BuildContext context) => TextDisplay(fileName, fileContent), ); } @@ -30,7 +31,7 @@ class OpenTextPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("Open a text file"), + title: const Text('Open a text file'), ), body: Center( child: Column( @@ -41,7 +42,7 @@ class OpenTextPage extends StatelessWidget { primary: Colors.blue, onPrimary: Colors.white, ), - child: Text('Press to open a text file (json, txt)'), + child: const Text('Press to open a text file (json, txt)'), onPressed: () => _openTextFile(context), ), ], @@ -53,15 +54,15 @@ class OpenTextPage extends StatelessWidget { /// Widget that displays a text file in a dialog class TextDisplay extends StatelessWidget { + /// Default Constructor + const TextDisplay(this.fileName, this.fileContent); + /// File's name final String fileName; /// File to display final String fileContent; - /// Default Constructor - TextDisplay(this.fileName, this.fileContent); - @override Widget build(BuildContext context) { return AlertDialog( @@ -71,7 +72,7 @@ class TextDisplay extends StatelessWidget { child: Text(fileContent), ), ), - actions: [ + actions: [ TextButton( child: const Text('Close'), onPressed: () => Navigator.pop(context), diff --git a/packages/file_selector/file_selector/example/lib/save_text_page.dart b/packages/file_selector/file_selector/example/lib/save_text_page.dart index 1610fc05164d..108ef89b0248 100644 --- a/packages/file_selector/file_selector/example/lib/save_text_page.dart +++ b/packages/file_selector/file_selector/example/lib/save_text_page.dart @@ -11,8 +11,8 @@ class SaveTextPage extends StatelessWidget { final TextEditingController _nameController = TextEditingController(); final TextEditingController _contentController = TextEditingController(); - void _saveFile() async { - String? path = await getSavePath(); + Future _saveFile() async { + final String? path = await getSavePath(); if (path == null) { // Operation was canceled by the user. return; @@ -20,7 +20,7 @@ class SaveTextPage extends StatelessWidget { final String text = _contentController.text; final String fileName = _nameController.text; final Uint8List fileData = Uint8List.fromList(text.codeUnits); - final String fileMimeType = 'text/plain'; + const String fileMimeType = 'text/plain'; final XFile textFile = XFile.fromData(fileData, mimeType: fileMimeType, name: fileName); await textFile.saveTo(path); @@ -30,7 +30,7 @@ class SaveTextPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text("Save text into a file"), + title: const Text('Save text into a file'), ), body: Center( child: Column( @@ -42,7 +42,7 @@ class SaveTextPage extends StatelessWidget { minLines: 1, maxLines: 12, controller: _nameController, - decoration: InputDecoration( + decoration: const InputDecoration( hintText: '(Optional) Suggest File Name', ), ), @@ -53,18 +53,18 @@ class SaveTextPage extends StatelessWidget { minLines: 1, maxLines: 12, controller: _contentController, - decoration: InputDecoration( + decoration: const InputDecoration( hintText: 'Enter File Contents', ), ), ), - SizedBox(height: 10), + const SizedBox(height: 10), ElevatedButton( style: ElevatedButton.styleFrom( primary: Colors.blue, onPrimary: Colors.white, ), - child: Text('Press to save a text file'), + child: const Text('Press to save a text file'), onPressed: () => _saveFile(), ), ], diff --git a/packages/file_selector/file_selector/example/pubspec.yaml b/packages/file_selector/file_selector/example/pubspec.yaml index 4987c836ad63..531f4790afd0 100644 --- a/packages/file_selector/file_selector/example/pubspec.yaml +++ b/packages/file_selector/file_selector/example/pubspec.yaml @@ -8,9 +8,6 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: - flutter: - sdk: flutter - file_selector: # When depending on this package from a real application you should use: # file_selector: ^x.y.z @@ -18,6 +15,8 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ + flutter: + sdk: flutter dev_dependencies: flutter_test: diff --git a/packages/file_selector/file_selector/test/file_selector_test.dart b/packages/file_selector/file_selector/test/file_selector_test.dart index 6d5e215eeb01..6ab0bd975036 100644 --- a/packages/file_selector/file_selector/test/file_selector_test.dart +++ b/packages/file_selector/file_selector/test/file_selector_test.dart @@ -2,23 +2,23 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_test/flutter_test.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:file_selector/file_selector.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:test/fake.dart'; void main() { late FakeFileSelector fakePlatformImplementation; - final initialDirectory = '/home/flutteruser'; - final confirmButtonText = 'Use this profile picture'; - final suggestedName = 'suggested_name'; - final acceptedTypeGroups = [ - XTypeGroup(label: 'documents', mimeTypes: [ + const String initialDirectory = '/home/flutteruser'; + const String confirmButtonText = 'Use this profile picture'; + const String suggestedName = 'suggested_name'; + final List acceptedTypeGroups = [ + XTypeGroup(label: 'documents', mimeTypes: [ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessing', ]), - XTypeGroup(label: 'images', extensions: [ + XTypeGroup(label: 'images', extensions: [ 'jpg', 'png', ]), @@ -30,7 +30,7 @@ void main() { }); group('openFile', () { - final expectedFile = XFile('path'); + final XFile expectedFile = XFile('path'); test('works', () async { fakePlatformImplementation @@ -40,7 +40,7 @@ void main() { acceptedTypeGroups: acceptedTypeGroups) ..setFileResponse([expectedFile]); - final file = await openFile( + final XFile? file = await openFile( initialDirectory: initialDirectory, confirmButtonText: confirmButtonText, acceptedTypeGroups: acceptedTypeGroups, @@ -52,7 +52,7 @@ void main() { test('works with no arguments', () async { fakePlatformImplementation.setFileResponse([expectedFile]); - final file = await openFile(); + final XFile? file = await openFile(); expect(file, expectedFile); }); @@ -62,7 +62,7 @@ void main() { ..setExpectations(initialDirectory: initialDirectory) ..setFileResponse([expectedFile]); - final file = await openFile(initialDirectory: initialDirectory); + final XFile? file = await openFile(initialDirectory: initialDirectory); expect(file, expectedFile); }); @@ -71,7 +71,7 @@ void main() { ..setExpectations(confirmButtonText: confirmButtonText) ..setFileResponse([expectedFile]); - final file = await openFile(confirmButtonText: confirmButtonText); + final XFile? file = await openFile(confirmButtonText: confirmButtonText); expect(file, expectedFile); }); @@ -80,13 +80,14 @@ void main() { ..setExpectations(acceptedTypeGroups: acceptedTypeGroups) ..setFileResponse([expectedFile]); - final file = await openFile(acceptedTypeGroups: acceptedTypeGroups); + final XFile? file = + await openFile(acceptedTypeGroups: acceptedTypeGroups); expect(file, expectedFile); }); }); group('openFiles', () { - final expectedFiles = [XFile('path')]; + final List expectedFiles = [XFile('path')]; test('works', () async { fakePlatformImplementation @@ -96,19 +97,19 @@ void main() { acceptedTypeGroups: acceptedTypeGroups) ..setFileResponse(expectedFiles); - final file = await openFiles( + final List files = await openFiles( initialDirectory: initialDirectory, confirmButtonText: confirmButtonText, acceptedTypeGroups: acceptedTypeGroups, ); - expect(file, expectedFiles); + expect(files, expectedFiles); }); test('works with no arguments', () async { fakePlatformImplementation.setFileResponse(expectedFiles); - final files = await openFiles(); + final List files = await openFiles(); expect(files, expectedFiles); }); @@ -118,7 +119,8 @@ void main() { ..setExpectations(initialDirectory: initialDirectory) ..setFileResponse(expectedFiles); - final files = await openFiles(initialDirectory: initialDirectory); + final List files = + await openFiles(initialDirectory: initialDirectory); expect(files, expectedFiles); }); @@ -127,7 +129,8 @@ void main() { ..setExpectations(confirmButtonText: confirmButtonText) ..setFileResponse(expectedFiles); - final files = await openFiles(confirmButtonText: confirmButtonText); + final List files = + await openFiles(confirmButtonText: confirmButtonText); expect(files, expectedFiles); }); @@ -136,13 +139,14 @@ void main() { ..setExpectations(acceptedTypeGroups: acceptedTypeGroups) ..setFileResponse(expectedFiles); - final files = await openFiles(acceptedTypeGroups: acceptedTypeGroups); + final List files = + await openFiles(acceptedTypeGroups: acceptedTypeGroups); expect(files, expectedFiles); }); }); group('getSavePath', () { - final expectedSavePath = '/example/path'; + const String expectedSavePath = '/example/path'; test('works', () async { fakePlatformImplementation @@ -153,7 +157,7 @@ void main() { suggestedName: suggestedName) ..setPathResponse(expectedSavePath); - final savePath = await getSavePath( + final String? savePath = await getSavePath( initialDirectory: initialDirectory, confirmButtonText: confirmButtonText, acceptedTypeGroups: acceptedTypeGroups, @@ -166,7 +170,7 @@ void main() { test('works with no arguments', () async { fakePlatformImplementation.setPathResponse(expectedSavePath); - final savePath = await getSavePath(); + final String? savePath = await getSavePath(); expect(savePath, expectedSavePath); }); @@ -175,7 +179,8 @@ void main() { ..setExpectations(initialDirectory: initialDirectory) ..setPathResponse(expectedSavePath); - final savePath = await getSavePath(initialDirectory: initialDirectory); + final String? savePath = + await getSavePath(initialDirectory: initialDirectory); expect(savePath, expectedSavePath); }); @@ -184,7 +189,8 @@ void main() { ..setExpectations(confirmButtonText: confirmButtonText) ..setPathResponse(expectedSavePath); - final savePath = await getSavePath(confirmButtonText: confirmButtonText); + final String? savePath = + await getSavePath(confirmButtonText: confirmButtonText); expect(savePath, expectedSavePath); }); @@ -193,7 +199,7 @@ void main() { ..setExpectations(acceptedTypeGroups: acceptedTypeGroups) ..setPathResponse(expectedSavePath); - final savePath = + final String? savePath = await getSavePath(acceptedTypeGroups: acceptedTypeGroups); expect(savePath, expectedSavePath); }); @@ -203,13 +209,13 @@ void main() { ..setExpectations(suggestedName: suggestedName) ..setPathResponse(expectedSavePath); - final savePath = await getSavePath(suggestedName: suggestedName); + final String? savePath = await getSavePath(suggestedName: suggestedName); expect(savePath, expectedSavePath); }); }); group('getDirectoryPath', () { - final expectedDirectoryPath = '/example/path'; + const String expectedDirectoryPath = '/example/path'; test('works', () async { fakePlatformImplementation @@ -218,7 +224,7 @@ void main() { confirmButtonText: confirmButtonText) ..setPathResponse(expectedDirectoryPath); - final directoryPath = await getDirectoryPath( + final String? directoryPath = await getDirectoryPath( initialDirectory: initialDirectory, confirmButtonText: confirmButtonText, ); @@ -229,7 +235,7 @@ void main() { test('works with no arguments', () async { fakePlatformImplementation.setPathResponse(expectedDirectoryPath); - final directoryPath = await getDirectoryPath(); + final String? directoryPath = await getDirectoryPath(); expect(directoryPath, expectedDirectoryPath); }); @@ -238,7 +244,7 @@ void main() { ..setExpectations(initialDirectory: initialDirectory) ..setPathResponse(expectedDirectoryPath); - final directoryPath = + final String? directoryPath = await getDirectoryPath(initialDirectory: initialDirectory); expect(directoryPath, expectedDirectoryPath); }); @@ -248,7 +254,7 @@ void main() { ..setExpectations(confirmButtonText: confirmButtonText) ..setPathResponse(expectedDirectoryPath); - final directoryPath = + final String? directoryPath = await getDirectoryPath(confirmButtonText: confirmButtonText); expect(directoryPath, expectedDirectoryPath); }); @@ -295,7 +301,7 @@ class FakeFileSelector extends Fake }) async { expect(acceptedTypeGroups, this.acceptedTypeGroups); expect(initialDirectory, this.initialDirectory); - expect(suggestedName, this.suggestedName); + expect(suggestedName, suggestedName); return files?[0]; } @@ -307,7 +313,7 @@ class FakeFileSelector extends Fake }) async { expect(acceptedTypeGroups, this.acceptedTypeGroups); expect(initialDirectory, this.initialDirectory); - expect(suggestedName, this.suggestedName); + expect(suggestedName, suggestedName); return files!; } diff --git a/packages/file_selector/file_selector_platform_interface/CHANGELOG.md b/packages/file_selector/file_selector_platform_interface/CHANGELOG.md index ba595addfcaf..95701504fed5 100644 --- a/packages/file_selector/file_selector_platform_interface/CHANGELOG.md +++ b/packages/file_selector/file_selector_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Minor code cleanup for new analysis rules. + ## 2.0.2 * Update platform_plugin_interface version requirement. diff --git a/packages/file_selector/file_selector_platform_interface/lib/src/method_channel/method_channel_file_selector.dart b/packages/file_selector/file_selector_platform_interface/lib/src/method_channel/method_channel_file_selector.dart index 34017acc90e0..28ec41db6dde 100644 --- a/packages/file_selector/file_selector_platform_interface/lib/src/method_channel/method_channel_file_selector.dart +++ b/packages/file_selector/file_selector_platform_interface/lib/src/method_channel/method_channel_file_selector.dart @@ -3,9 +3,8 @@ // found in the LICENSE file. import 'package:cross_file/cross_file.dart'; -import 'package:flutter/services.dart'; - import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; const MethodChannel _channel = @@ -27,8 +26,9 @@ class MethodChannelFileSelector extends FileSelectorPlatform { final List? path = await _channel.invokeListMethod( 'openFile', { - 'acceptedTypeGroups': - acceptedTypeGroups?.map((group) => group.toJSON()).toList(), + 'acceptedTypeGroups': acceptedTypeGroups + ?.map((XTypeGroup group) => group.toJSON()) + .toList(), 'initialDirectory': initialDirectory, 'confirmButtonText': confirmButtonText, 'multiple': false, @@ -47,14 +47,15 @@ class MethodChannelFileSelector extends FileSelectorPlatform { final List? pathList = await _channel.invokeListMethod( 'openFile', { - 'acceptedTypeGroups': - acceptedTypeGroups?.map((group) => group.toJSON()).toList(), + 'acceptedTypeGroups': acceptedTypeGroups + ?.map((XTypeGroup group) => group.toJSON()) + .toList(), 'initialDirectory': initialDirectory, 'confirmButtonText': confirmButtonText, 'multiple': true, }, ); - return pathList?.map((path) => XFile(path)).toList() ?? []; + return pathList?.map((String path) => XFile(path)).toList() ?? []; } /// Gets the path from a save dialog @@ -68,8 +69,9 @@ class MethodChannelFileSelector extends FileSelectorPlatform { return _channel.invokeMethod( 'getSavePath', { - 'acceptedTypeGroups': - acceptedTypeGroups?.map((group) => group.toJSON()).toList(), + 'acceptedTypeGroups': acceptedTypeGroups + ?.map((XTypeGroup group) => group.toJSON()) + .toList(), 'initialDirectory': initialDirectory, 'suggestedName': suggestedName, 'confirmButtonText': confirmButtonText, diff --git a/packages/file_selector/file_selector_platform_interface/lib/src/types/x_type_group/x_type_group.dart b/packages/file_selector/file_selector_platform_interface/lib/src/types/x_type_group/x_type_group.dart index 3e3326379610..2146131023e1 100644 --- a/packages/file_selector/file_selector_platform_interface/lib/src/types/x_type_group/x_type_group.dart +++ b/packages/file_selector/file_selector_platform_interface/lib/src/types/x_type_group/x_type_group.dart @@ -14,7 +14,7 @@ class XTypeGroup { this.mimeTypes, this.macUTIs, this.webWildCards, - }) : this.extensions = _removeLeadingDots(extensions); + }) : extensions = _removeLeadingDots(extensions); /// The 'name' or reference to this group of types final String? label; @@ -42,6 +42,7 @@ class XTypeGroup { }; } - static List? _removeLeadingDots(List? exts) => - exts?.map((ext) => ext.startsWith('.') ? ext.substring(1) : ext).toList(); + static List? _removeLeadingDots(List? exts) => exts + ?.map((String ext) => ext.startsWith('.') ? ext.substring(1) : ext) + .toList(); } diff --git a/packages/file_selector/file_selector_platform_interface/lib/src/web_helpers/web_helpers.dart b/packages/file_selector/file_selector_platform_interface/lib/src/web_helpers/web_helpers.dart index 0f157ed0be5a..bc7136f80bd6 100644 --- a/packages/file_selector/file_selector_platform_interface/lib/src/web_helpers/web_helpers.dart +++ b/packages/file_selector/file_selector_platform_interface/lib/src/web_helpers/web_helpers.dart @@ -6,7 +6,7 @@ import 'dart:html'; /// Create anchor element with download attribute AnchorElement createAnchorElement(String href, String? suggestedName) { - final element = AnchorElement(href: href); + final AnchorElement element = AnchorElement(href: href); if (suggestedName == null) { element.download = 'download'; @@ -27,7 +27,7 @@ void addElementToContainerAndClick(Element container, Element element) { /// Initializes a DOM container where we can host elements. Element ensureInitialized(String id) { - var target = querySelector('#${id}'); + Element? target = querySelector('#$id'); if (target == null) { final Element targetElement = Element.tag('flt-x-file')..id = id; diff --git a/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart b/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart index f5b609c93ed3..a5b22b977d69 100644 --- a/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart +++ b/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_test/flutter_test.dart'; - import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:file_selector_platform_interface/src/method_channel/method_channel_file_selector.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { group('$FileSelectorPlatform', () { diff --git a/packages/file_selector/file_selector_platform_interface/test/method_channel_file_selector_test.dart b/packages/file_selector/file_selector_platform_interface/test/method_channel_file_selector_test.dart index 96a3c2d4f4c9..33f9fbf45a8b 100644 --- a/packages/file_selector/file_selector_platform_interface/test/method_channel_file_selector_test.dart +++ b/packages/file_selector/file_selector_platform_interface/test/method_channel_file_selector_test.dart @@ -2,17 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:file_selector_platform_interface/src/method_channel/method_channel_file_selector.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('$MethodChannelFileSelector()', () { - MethodChannelFileSelector plugin = MethodChannelFileSelector(); + final MethodChannelFileSelector plugin = MethodChannelFileSelector(); final List log = []; @@ -27,27 +26,31 @@ void main() { group('#openFile', () { test('passes the accepted type groups correctly', () async { - final group = XTypeGroup( + final XTypeGroup group = XTypeGroup( label: 'text', - extensions: ['txt'], - mimeTypes: ['text/plain'], - macUTIs: ['public.text'], + extensions: ['txt'], + mimeTypes: ['text/plain'], + macUTIs: ['public.text'], ); - final groupTwo = XTypeGroup( + final XTypeGroup groupTwo = XTypeGroup( label: 'image', - extensions: ['jpg'], - mimeTypes: ['image/jpg'], - macUTIs: ['public.image'], - webWildCards: ['image/*']); + extensions: ['jpg'], + mimeTypes: ['image/jpg'], + macUTIs: ['public.image'], + webWildCards: ['image/*']); - await plugin.openFile(acceptedTypeGroups: [group, groupTwo]); + await plugin + .openFile(acceptedTypeGroups: [group, groupTwo]); expect( log, [ isMethodCall('openFile', arguments: { - 'acceptedTypeGroups': [group.toJSON(), groupTwo.toJSON()], + 'acceptedTypeGroups': >[ + group.toJSON(), + groupTwo.toJSON() + ], 'initialDirectory': null, 'confirmButtonText': null, 'multiple': false, @@ -56,14 +59,14 @@ void main() { ); }); test('passes initialDirectory correctly', () async { - await plugin.openFile(initialDirectory: "/example/directory"); + await plugin.openFile(initialDirectory: '/example/directory'); expect( log, [ isMethodCall('openFile', arguments: { 'acceptedTypeGroups': null, - 'initialDirectory': "/example/directory", + 'initialDirectory': '/example/directory', 'confirmButtonText': null, 'multiple': false, }), @@ -71,7 +74,7 @@ void main() { ); }); test('passes confirmButtonText correctly', () async { - await plugin.openFile(confirmButtonText: "Open File"); + await plugin.openFile(confirmButtonText: 'Open File'); expect( log, @@ -79,7 +82,7 @@ void main() { isMethodCall('openFile', arguments: { 'acceptedTypeGroups': null, 'initialDirectory': null, - 'confirmButtonText': "Open File", + 'confirmButtonText': 'Open File', 'multiple': false, }), ], @@ -88,27 +91,31 @@ void main() { }); group('#openFiles', () { test('passes the accepted type groups correctly', () async { - final group = XTypeGroup( + final XTypeGroup group = XTypeGroup( label: 'text', - extensions: ['txt'], - mimeTypes: ['text/plain'], - macUTIs: ['public.text'], + extensions: ['txt'], + mimeTypes: ['text/plain'], + macUTIs: ['public.text'], ); - final groupTwo = XTypeGroup( + final XTypeGroup groupTwo = XTypeGroup( label: 'image', - extensions: ['jpg'], - mimeTypes: ['image/jpg'], - macUTIs: ['public.image'], - webWildCards: ['image/*']); + extensions: ['jpg'], + mimeTypes: ['image/jpg'], + macUTIs: ['public.image'], + webWildCards: ['image/*']); - await plugin.openFiles(acceptedTypeGroups: [group, groupTwo]); + await plugin + .openFiles(acceptedTypeGroups: [group, groupTwo]); expect( log, [ isMethodCall('openFile', arguments: { - 'acceptedTypeGroups': [group.toJSON(), groupTwo.toJSON()], + 'acceptedTypeGroups': >[ + group.toJSON(), + groupTwo.toJSON() + ], 'initialDirectory': null, 'confirmButtonText': null, 'multiple': true, @@ -117,14 +124,14 @@ void main() { ); }); test('passes initialDirectory correctly', () async { - await plugin.openFiles(initialDirectory: "/example/directory"); + await plugin.openFiles(initialDirectory: '/example/directory'); expect( log, [ isMethodCall('openFile', arguments: { 'acceptedTypeGroups': null, - 'initialDirectory': "/example/directory", + 'initialDirectory': '/example/directory', 'confirmButtonText': null, 'multiple': true, }), @@ -132,7 +139,7 @@ void main() { ); }); test('passes confirmButtonText correctly', () async { - await plugin.openFiles(confirmButtonText: "Open File"); + await plugin.openFiles(confirmButtonText: 'Open File'); expect( log, @@ -140,7 +147,7 @@ void main() { isMethodCall('openFile', arguments: { 'acceptedTypeGroups': null, 'initialDirectory': null, - 'confirmButtonText': "Open File", + 'confirmButtonText': 'Open File', 'multiple': true, }), ], @@ -150,27 +157,31 @@ void main() { group('#getSavePath', () { test('passes the accepted type groups correctly', () async { - final group = XTypeGroup( + final XTypeGroup group = XTypeGroup( label: 'text', - extensions: ['txt'], - mimeTypes: ['text/plain'], - macUTIs: ['public.text'], + extensions: ['txt'], + mimeTypes: ['text/plain'], + macUTIs: ['public.text'], ); - final groupTwo = XTypeGroup( + final XTypeGroup groupTwo = XTypeGroup( label: 'image', - extensions: ['jpg'], - mimeTypes: ['image/jpg'], - macUTIs: ['public.image'], - webWildCards: ['image/*']); + extensions: ['jpg'], + mimeTypes: ['image/jpg'], + macUTIs: ['public.image'], + webWildCards: ['image/*']); - await plugin.getSavePath(acceptedTypeGroups: [group, groupTwo]); + await plugin + .getSavePath(acceptedTypeGroups: [group, groupTwo]); expect( log, [ isMethodCall('getSavePath', arguments: { - 'acceptedTypeGroups': [group.toJSON(), groupTwo.toJSON()], + 'acceptedTypeGroups': >[ + group.toJSON(), + groupTwo.toJSON() + ], 'initialDirectory': null, 'suggestedName': null, 'confirmButtonText': null, @@ -179,14 +190,14 @@ void main() { ); }); test('passes initialDirectory correctly', () async { - await plugin.getSavePath(initialDirectory: "/example/directory"); + await plugin.getSavePath(initialDirectory: '/example/directory'); expect( log, [ isMethodCall('getSavePath', arguments: { 'acceptedTypeGroups': null, - 'initialDirectory': "/example/directory", + 'initialDirectory': '/example/directory', 'suggestedName': null, 'confirmButtonText': null, }), @@ -194,7 +205,7 @@ void main() { ); }); test('passes confirmButtonText correctly', () async { - await plugin.getSavePath(confirmButtonText: "Open File"); + await plugin.getSavePath(confirmButtonText: 'Open File'); expect( log, @@ -203,34 +214,34 @@ void main() { 'acceptedTypeGroups': null, 'initialDirectory': null, 'suggestedName': null, - 'confirmButtonText': "Open File", + 'confirmButtonText': 'Open File', }), ], ); }); group('#getDirectoryPath', () { test('passes initialDirectory correctly', () async { - await plugin.getDirectoryPath(initialDirectory: "/example/directory"); + await plugin.getDirectoryPath(initialDirectory: '/example/directory'); expect( log, [ isMethodCall('getDirectoryPath', arguments: { - 'initialDirectory': "/example/directory", + 'initialDirectory': '/example/directory', 'confirmButtonText': null, }), ], ); }); test('passes confirmButtonText correctly', () async { - await plugin.getDirectoryPath(confirmButtonText: "Open File"); + await plugin.getDirectoryPath(confirmButtonText: 'Open File'); expect( log, [ isMethodCall('getDirectoryPath', arguments: { 'initialDirectory': null, - 'confirmButtonText': "Open File", + 'confirmButtonText': 'Open File', }), ], ); diff --git a/packages/file_selector/file_selector_platform_interface/test/x_type_group_test.dart b/packages/file_selector/file_selector_platform_interface/test/x_type_group_test.dart index e85a4929e411..84f5ca1f0bd2 100644 --- a/packages/file_selector/file_selector_platform_interface/test/x_type_group_test.dart +++ b/packages/file_selector/file_selector_platform_interface/test/x_type_group_test.dart @@ -2,19 +2,19 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_test/flutter_test.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { group('XTypeGroup', () { test('toJSON() creates correct map', () { - final label = 'test group'; - final extensions = ['txt', 'jpg']; - final mimeTypes = ['text/plain']; - final macUTIs = ['public.plain-text']; - final webWildCards = ['image/*']; + const String label = 'test group'; + final List extensions = ['txt', 'jpg']; + final List mimeTypes = ['text/plain']; + final List macUTIs = ['public.plain-text']; + final List webWildCards = ['image/*']; - final group = XTypeGroup( + final XTypeGroup group = XTypeGroup( label: label, extensions: extensions, mimeTypes: mimeTypes, @@ -22,7 +22,7 @@ void main() { webWildCards: webWildCards, ); - final jsonMap = group.toJSON(); + final Map jsonMap = group.toJSON(); expect(jsonMap['label'], label); expect(jsonMap['extensions'], extensions); expect(jsonMap['mimeTypes'], mimeTypes); @@ -31,11 +31,11 @@ void main() { }); test('A wildcard group can be created', () { - final group = XTypeGroup( + final XTypeGroup group = XTypeGroup( label: 'Any', ); - final jsonMap = group.toJSON(); + final Map jsonMap = group.toJSON(); expect(jsonMap['extensions'], null); expect(jsonMap['mimeTypes'], null); expect(jsonMap['macUTIs'], null); @@ -43,10 +43,10 @@ void main() { }); test('Leading dots are removed from extensions', () { - final extensions = ['.txt', '.jpg']; - final group = XTypeGroup(extensions: extensions); + final List extensions = ['.txt', '.jpg']; + final XTypeGroup group = XTypeGroup(extensions: extensions); - expect(group.extensions, ['txt', 'jpg']); + expect(group.extensions, ['txt', 'jpg']); }); }); } diff --git a/packages/file_selector/file_selector_web/CHANGELOG.md b/packages/file_selector/file_selector_web/CHANGELOG.md index e2a863643027..dabd7173868c 100644 --- a/packages/file_selector/file_selector_web/CHANGELOG.md +++ b/packages/file_selector/file_selector_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Minor code cleanup for new analysis rules. + ## 0.8.1+2 * Add `implements` to pubspec. diff --git a/packages/file_selector/file_selector_web/example/integration_test/dom_helper_test.dart b/packages/file_selector/file_selector_web/example/integration_test/dom_helper_test.dart index f000574861ab..ee1af8cb62fd 100644 --- a/packages/file_selector/file_selector_web/example/integration_test/dom_helper_test.dart +++ b/packages/file_selector/file_selector_web/example/integration_test/dom_helper_test.dart @@ -3,10 +3,11 @@ // found in the LICENSE file. import 'dart:html'; + +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_web/src/dom_helper.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:file_selector_web/src/dom_helper.dart'; -import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; void main() { group('dom_helper', () { @@ -15,7 +16,7 @@ void main() { late FileUploadInputElement input; FileList? createFileList(List files) { - final dataTransfer = DataTransfer(); + final DataTransfer dataTransfer = DataTransfer(); files.forEach(dataTransfer.items!.add); return dataTransfer.files as FileList?; } @@ -31,15 +32,15 @@ void main() { }); group('getFiles', () { - final mockFile1 = File(['123456'], 'file1.txt'); - final mockFile2 = File([], 'file2.txt'); + final File mockFile1 = File(['123456'], 'file1.txt'); + final File mockFile2 = File([], 'file2.txt'); testWidgets('works', (_) async { final Future> futureFiles = domHelper.getFiles( input: input, ); - setFilesAndTriggerChange([mockFile1, mockFile2]); + setFilesAndTriggerChange([mockFile1, mockFile2]); final List files = await futureFiles; @@ -62,7 +63,7 @@ void main() { // It should work the first time futureFiles = domHelper.getFiles(input: input); - setFilesAndTriggerChange([mockFile1]); + setFilesAndTriggerChange([mockFile1]); files = await futureFiles; @@ -71,7 +72,7 @@ void main() { // The same input should work more than once futureFiles = domHelper.getFiles(input: input); - setFilesAndTriggerChange([mockFile2]); + setFilesAndTriggerChange([mockFile2]); files = await futureFiles; @@ -80,14 +81,14 @@ void main() { }); testWidgets('sets the attributes and clicks it', (_) async { - final accept = '.jpg,.png'; - final multiple = true; + const String accept = '.jpg,.png'; + const bool multiple = true; bool wasClicked = false; //ignore: unawaited_futures input.onClick.first.then((_) => wasClicked = true); - final futureFile = domHelper.getFiles( + final Future> futureFile = domHelper.getFiles( accept: accept, multiple: multiple, input: input, @@ -103,7 +104,7 @@ void main() { 'The should be clicked otherwise no dialog will be shown', ); - setFilesAndTriggerChange([]); + setFilesAndTriggerChange([]); await futureFile; // It should be already removed from the DOM after the file is resolved. diff --git a/packages/file_selector/file_selector_web/example/integration_test/file_selector_web_test.dart b/packages/file_selector/file_selector_web/example/integration_test/file_selector_web_test.dart index c16aa1cf454e..fe57d1d1e15d 100644 --- a/packages/file_selector/file_selector_web/example/integration_test/file_selector_web_test.dart +++ b/packages/file_selector/file_selector_web/example/integration_test/file_selector_web_test.dart @@ -4,11 +4,12 @@ import 'dart:html'; import 'dart:typed_data'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; + import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:file_selector_web/file_selector_web.dart'; import 'package:file_selector_web/src/dom_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; void main() { group('FileSelectorWeb', () { @@ -16,23 +17,25 @@ void main() { group('openFile', () { testWidgets('works', (WidgetTester _) async { - final mockFile = createXFile('1001', 'identity.png'); + final XFile mockFile = createXFile('1001', 'identity.png'); - final mockDomHelper = MockDomHelper() - ..setFiles([mockFile]) + final MockDomHelper mockDomHelper = MockDomHelper() + ..setFiles([mockFile]) ..expectAccept('.jpg,.jpeg,image/png,image/*') ..expectMultiple(false); - final plugin = FileSelectorWeb(domHelper: mockDomHelper); + final FileSelectorWeb plugin = + FileSelectorWeb(domHelper: mockDomHelper); - final typeGroup = XTypeGroup( + final XTypeGroup typeGroup = XTypeGroup( label: 'images', - extensions: ['jpg', 'jpeg'], - mimeTypes: ['image/png'], - webWildCards: ['image/*'], + extensions: ['jpg', 'jpeg'], + mimeTypes: ['image/png'], + webWildCards: ['image/*'], ); - final file = await plugin.openFile(acceptedTypeGroups: [typeGroup]); + final XFile file = + await plugin.openFile(acceptedTypeGroups: [typeGroup]); expect(file.name, mockFile.name); expect(await file.length(), 4); @@ -43,22 +46,24 @@ void main() { group('openFiles', () { testWidgets('works', (WidgetTester _) async { - final mockFile1 = createXFile('123456', 'file1.txt'); - final mockFile2 = createXFile('', 'file2.txt'); + final XFile mockFile1 = createXFile('123456', 'file1.txt'); + final XFile mockFile2 = createXFile('', 'file2.txt'); - final mockDomHelper = MockDomHelper() - ..setFiles([mockFile1, mockFile2]) + final MockDomHelper mockDomHelper = MockDomHelper() + ..setFiles([mockFile1, mockFile2]) ..expectAccept('.txt') ..expectMultiple(true); - final plugin = FileSelectorWeb(domHelper: mockDomHelper); + final FileSelectorWeb plugin = + FileSelectorWeb(domHelper: mockDomHelper); - final typeGroup = XTypeGroup( + final XTypeGroup typeGroup = XTypeGroup( label: 'files', - extensions: ['.txt'], + extensions: ['.txt'], ); - final files = await plugin.openFiles(acceptedTypeGroups: [typeGroup]); + final List files = + await plugin.openFiles(acceptedTypeGroups: [typeGroup]); expect(files.length, 2); @@ -76,8 +81,8 @@ void main() { group('getSavePath', () { testWidgets('returns non-null', (WidgetTester _) async { - final plugin = FileSelectorWeb(); - final savePath = plugin.getSavePath(); + final FileSelectorWeb plugin = FileSelectorWeb(); + final Future savePath = plugin.getSavePath(); expect(await savePath, isNotNull); }); }); @@ -99,7 +104,7 @@ class MockDomHelper implements DomHelper { reason: 'Expected "accept" value does not match.'); expect(multiple, _expectedMultiple, reason: 'Expected "multiple" value does not match.'); - return Future.value(_files); + return Future>.value(_files); } void setFiles(List files) { @@ -116,6 +121,6 @@ class MockDomHelper implements DomHelper { } XFile createXFile(String content, String name) { - final data = Uint8List.fromList(content.codeUnits); + final Uint8List data = Uint8List.fromList(content.codeUnits); return XFile.fromData(data, name: name, lastModified: DateTime.now()); } diff --git a/packages/file_selector/file_selector_web/example/lib/main.dart b/packages/file_selector/file_selector_web/example/lib/main.dart index e1a38dcdcd46..341913a18490 100644 --- a/packages/file_selector/file_selector_web/example/lib/main.dart +++ b/packages/file_selector/file_selector_web/example/lib/main.dart @@ -17,7 +17,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { @override Widget build(BuildContext context) { - return Directionality( + return const Directionality( textDirection: TextDirection.ltr, child: Text('Testing... Look at the console output for results!'), ); diff --git a/packages/file_selector/file_selector_web/lib/file_selector_web.dart b/packages/file_selector/file_selector_web/lib/file_selector_web.dart index f7c10b36a186..8f4ca202593e 100644 --- a/packages/file_selector/file_selector_web/lib/file_selector_web.dart +++ b/packages/file_selector/file_selector_web/lib/file_selector_web.dart @@ -3,16 +3,23 @@ // found in the LICENSE file. import 'dart:async'; -import 'package:meta/meta.dart'; -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:file_selector_web/src/dom_helper.dart'; import 'package:file_selector_web/src/utils.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:meta/meta.dart'; /// The web implementation of [FileSelectorPlatform]. /// /// This class implements the `package:file_selector` functionality for the web. class FileSelectorWeb extends FileSelectorPlatform { + /// Default constructor, initializes _domHelper that we can use + /// to interact with the DOM. + /// overrides parameter allows for testing to override functions + FileSelectorWeb({@visibleForTesting DomHelper? domHelper}) + : _domHelper = domHelper ?? DomHelper(); + final DomHelper _domHelper; /// Registers this class as the default instance of [FileSelectorPlatform]. @@ -20,19 +27,14 @@ class FileSelectorWeb extends FileSelectorPlatform { FileSelectorPlatform.instance = FileSelectorWeb(); } - /// Default constructor, initializes _domHelper that we can use - /// to interact with the DOM. - /// overrides parameter allows for testing to override functions - FileSelectorWeb({@visibleForTesting DomHelper? domHelper}) - : _domHelper = domHelper ?? DomHelper(); - @override Future openFile({ List? acceptedTypeGroups, String? initialDirectory, String? confirmButtonText, }) async { - final files = await _openFiles(acceptedTypeGroups: acceptedTypeGroups); + final List files = + await _openFiles(acceptedTypeGroups: acceptedTypeGroups); return files.first; } @@ -68,7 +70,7 @@ class FileSelectorWeb extends FileSelectorPlatform { List? acceptedTypeGroups, bool multiple = false, }) async { - final accept = acceptedTypesToString(acceptedTypeGroups); + final String accept = acceptedTypesToString(acceptedTypeGroups); return _domHelper.getFiles( accept: accept, multiple: multiple, diff --git a/packages/file_selector/file_selector_web/lib/src/dom_helper.dart b/packages/file_selector/file_selector_web/lib/src/dom_helper.dart index 0d251af9cc7f..06c13d968484 100644 --- a/packages/file_selector/file_selector_web/lib/src/dom_helper.dart +++ b/packages/file_selector/file_selector_web/lib/src/dom_helper.dart @@ -4,27 +4,28 @@ import 'dart:async'; import 'dart:html'; -import 'package:meta/meta.dart'; -import 'package:flutter/services.dart'; + import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; /// Class to manipulate the DOM with the intention of reading files from it. class DomHelper { - final _container = Element.tag('file-selector'); - /// Default constructor, initializes the container DOM element. DomHelper() { - final body = querySelector('body')!; + final Element body = querySelector('body')!; body.children.add(_container); } + final Element _container = Element.tag('file-selector'); + /// Sets the attributes and waits for a file to be selected. Future> getFiles({ String accept = '', bool multiple = false, @visibleForTesting FileUploadInputElement? input, }) { - final Completer> completer = Completer(); + final Completer> completer = Completer>(); final FileUploadInputElement inputElement = input ?? FileUploadInputElement(); @@ -41,9 +42,9 @@ class DomHelper { completer.complete(files); }); - inputElement.onError.first.then((event) { + inputElement.onError.first.then((Event event) { final ErrorEvent error = event as ErrorEvent; - final platformException = PlatformException( + final PlatformException platformException = PlatformException( code: error.type, message: error.message, ); diff --git a/packages/file_selector/file_selector_web/lib/src/utils.dart b/packages/file_selector/file_selector_web/lib/src/utils.dart index e52c00d1c223..6a534645fda6 100644 --- a/packages/file_selector/file_selector_web/lib/src/utils.dart +++ b/packages/file_selector/file_selector_web/lib/src/utils.dart @@ -6,9 +6,11 @@ import 'package:file_selector_platform_interface/file_selector_platform_interfac /// Convert list of XTypeGroups to a comma-separated string String acceptedTypesToString(List? acceptedTypes) { - if (acceptedTypes == null) return ''; - final List allTypes = []; - for (final group in acceptedTypes) { + if (acceptedTypes == null) { + return ''; + } + final List allTypes = []; + for (final XTypeGroup group in acceptedTypes) { _assertTypeGroupIsValid(group); if (group.extensions != null) { allTypes.addAll(group.extensions!.map(_normalizeExtension)); diff --git a/packages/file_selector/file_selector_web/test/utils_test.dart b/packages/file_selector/file_selector_web/test/utils_test.dart index 2951af24dff9..9bddfd2e6304 100644 --- a/packages/file_selector/file_selector_web/test/utils_test.dart +++ b/packages/file_selector/file_selector_web/test/utils_test.dart @@ -2,54 +2,55 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_test/flutter_test.dart'; -import 'package:file_selector_web/src/utils.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_web/src/utils.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { group('FileSelectorWeb utils', () { group('acceptedTypesToString', () { test('works', () { - final List acceptedTypes = [ - XTypeGroup(label: 'images', webWildCards: ['images/*']), - XTypeGroup(label: 'jpgs', extensions: ['jpg', 'jpeg']), - XTypeGroup(label: 'pngs', mimeTypes: ['image/png']), + final List acceptedTypes = [ + XTypeGroup(label: 'images', webWildCards: ['images/*']), + XTypeGroup(label: 'jpgs', extensions: ['jpg', 'jpeg']), + XTypeGroup(label: 'pngs', mimeTypes: ['image/png']), ]; - final accepts = acceptedTypesToString(acceptedTypes); + final String accepts = acceptedTypesToString(acceptedTypes); expect(accepts, 'images/*,.jpg,.jpeg,image/png'); }); test('works with an empty list', () { - final List acceptedTypes = []; - final accepts = acceptedTypesToString(acceptedTypes); + final List acceptedTypes = []; + final String accepts = acceptedTypesToString(acceptedTypes); expect(accepts, ''); }); test('works with extensions', () { - final List acceptedTypes = [ - XTypeGroup(label: 'jpgs', extensions: ['jpeg', 'jpg']), - XTypeGroup(label: 'pngs', extensions: ['png']), + final List acceptedTypes = [ + XTypeGroup(label: 'jpgs', extensions: ['jpeg', 'jpg']), + XTypeGroup(label: 'pngs', extensions: ['png']), ]; - final accepts = acceptedTypesToString(acceptedTypes); + final String accepts = acceptedTypesToString(acceptedTypes); expect(accepts, '.jpeg,.jpg,.png'); }); test('works with mime types', () { - final List acceptedTypes = [ - XTypeGroup(label: 'jpgs', mimeTypes: ['image/jpeg', 'image/jpg']), - XTypeGroup(label: 'pngs', mimeTypes: ['image/png']), + final List acceptedTypes = [ + XTypeGroup( + label: 'jpgs', mimeTypes: ['image/jpeg', 'image/jpg']), + XTypeGroup(label: 'pngs', mimeTypes: ['image/png']), ]; - final accepts = acceptedTypesToString(acceptedTypes); + final String accepts = acceptedTypesToString(acceptedTypes); expect(accepts, 'image/jpeg,image/jpg,image/png'); }); test('works with web wild cards', () { - final List acceptedTypes = [ - XTypeGroup(label: 'images', webWildCards: ['image/*']), - XTypeGroup(label: 'audios', webWildCards: ['audio/*']), - XTypeGroup(label: 'videos', webWildCards: ['video/*']), + final List acceptedTypes = [ + XTypeGroup(label: 'images', webWildCards: ['image/*']), + XTypeGroup(label: 'audios', webWildCards: ['audio/*']), + XTypeGroup(label: 'videos', webWildCards: ['video/*']), ]; - final accepts = acceptedTypesToString(acceptedTypes); + final String accepts = acceptedTypesToString(acceptedTypes); expect(accepts, 'image/*,audio/*,video/*'); }); }); diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml index 2e214757d90d..71d8fb825add 100644 --- a/script/configs/custom_analysis.yaml +++ b/script/configs/custom_analysis.yaml @@ -16,7 +16,6 @@ # TODO(ecosystem): Remove everything from this list. See: # https://github.com/flutter/flutter/issues/76229 - camera -- file_selector - google_maps_flutter - google_sign_in - image_picker From fe31e5292f14eee3b7a9c3a7375bb04acd159057 Mon Sep 17 00:00:00 2001 From: BeMacized Date: Wed, 29 Sep 2021 10:58:04 +0200 Subject: [PATCH 332/364] Handle restored purchases in iOS example app (#4392) --- packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md | 3 ++- .../in_app_purchase/in_app_purchase_ios/example/lib/main.dart | 3 ++- packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 7e3902b206ab..76cafa9201cc 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.1.3+5 +* Updated example app to handle restored purchases properly. * Update dev_dependency `build_runner` to ^2.0.0 and `json_serializable` to ^5.0.2. ## 0.1.3+4 diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart index 19884745bce8..15ab64b6ea80 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/main.dart @@ -399,7 +399,8 @@ class _MyAppState extends State<_MyApp> { } else { if (purchaseDetails.status == PurchaseStatus.error) { handleError(purchaseDetails.error!); - } else if (purchaseDetails.status == PurchaseStatus.purchased) { + } else if (purchaseDetails.status == PurchaseStatus.purchased || + purchaseDetails.status == PurchaseStatus.restored) { bool valid = await _verifyPurchase(purchaseDetails); if (valid) { deliverProduct(purchaseDetails); diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 30a57bb56c94..fdd769e90674 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.3+4 +version: 0.1.3+5 environment: sdk: ">=2.14.0 <3.0.0" From d9a4b753e7b41595fc9b7ebeee5ef1d65c9ad07d Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Wed, 29 Sep 2021 13:08:07 +0200 Subject: [PATCH 333/364] Handle `PurchaseStatus.restored` correctly in example. (#4393) --- packages/in_app_purchase/in_app_purchase/CHANGELOG.md | 5 +++++ .../in_app_purchase/in_app_purchase/example/lib/main.dart | 3 ++- packages/in_app_purchase/in_app_purchase/pubspec.yaml | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 95ba4f27d10a..859d0bb6432f 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.0.9 + +* Handle purchases with `PurchaseStatus.restored` correctly in the example App. +* Updated dependencies on `in_app_purchase_android` and `in_app_purchase_ios` to their latest versions (version 0.1.5 and 0.1.3+5 respectively). + ## 1.0.8 * Fix repository link in pubspec.yaml. diff --git a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart index 73ecadb3f15d..3cf7229fdbc2 100644 --- a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart @@ -429,7 +429,8 @@ class _MyAppState extends State<_MyApp> { } else { if (purchaseDetails.status == PurchaseStatus.error) { handleError(purchaseDetails.error!); - } else if (purchaseDetails.status == PurchaseStatus.purchased) { + } else if (purchaseDetails.status == PurchaseStatus.purchased || + purchaseDetails.status == PurchaseStatus.restored) { bool valid = await _verifyPurchase(purchaseDetails); if (valid) { deliverProduct(purchaseDetails); diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index 8b4510b3fce4..96570f7aa168 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 1.0.8 +version: 1.0.9 environment: sdk: ">=2.12.0 <3.0.0" @@ -20,8 +20,8 @@ dependencies: flutter: sdk: flutter in_app_purchase_platform_interface: ^1.0.0 - in_app_purchase_android: ^0.1.4 - in_app_purchase_ios: ^0.1.1 + in_app_purchase_android: ^0.1.5 + in_app_purchase_ios: ^0.1.3+5 dev_dependencies: flutter_driver: From a1304efe49e92da6999a765dbbdddd04e595ba29 Mon Sep 17 00:00:00 2001 From: Aman Verma Date: Thu, 30 Sep 2021 00:38:03 +0530 Subject: [PATCH 334/364] [webview_flutter] Fixed todos in FlutterWebView.java (#4394) --- .../webview_flutter_android/CHANGELOG.md | 4 +++ .../webviewflutter/FlutterWebView.java | 28 +++---------------- .../webview_flutter_android/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/webview_flutter/webview_flutter_android/CHANGELOG.md b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md index 917a3c73acb1..d4827a71e47d 100644 --- a/packages/webview_flutter/webview_flutter_android/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.15 + +* Added Overrides in FlutterWebView.java + ## 2.0.14 * Update example App so navigation menu loads immediatly but only becomes available when `WebViewController` is available (same behavior as example App in webview_flutter package). diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index 4651a5f5ae22..ff573c771960 100644 --- a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -188,48 +188,28 @@ public View getView() { return webView; } - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once flutter/engine#9727 rolls to stable. + @Override public void onInputConnectionUnlocked() { if (webView instanceof InputAwareWebView) { ((InputAwareWebView) webView).unlockInputConnection(); } } - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once flutter/engine#9727 rolls to stable. + @Override public void onInputConnectionLocked() { if (webView instanceof InputAwareWebView) { ((InputAwareWebView) webView).lockInputConnection(); } } - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once stable passes v1.10.9. + @Override public void onFlutterViewAttached(View flutterView) { if (webView instanceof InputAwareWebView) { ((InputAwareWebView) webView).setContainerView(flutterView); } } - // @Override - // This is overriding a method that hasn't rolled into stable Flutter yet. Including the - // annotation would cause compile time failures in versions of Flutter too old to include the new - // method. However leaving it raw like this means that the method will be ignored in old versions - // of Flutter but used as an override anyway wherever it's actually defined. - // TODO(mklim): Add the @Override annotation once stable passes v1.10.9. + @Override public void onFlutterViewDetached() { if (webView instanceof InputAwareWebView) { ((InputAwareWebView) webView).setContainerView(null); diff --git a/packages/webview_flutter/webview_flutter_android/pubspec.yaml b/packages/webview_flutter/webview_flutter_android/pubspec.yaml index 4a3d4cb1d942..36f186087c08 100644 --- a/packages/webview_flutter/webview_flutter_android/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter_android/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter_android description: A Flutter plugin that provides a WebView widget on Android. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.0.14 +version: 2.0.15 environment: sdk: ">=2.14.0 <3.0.0" From 90b2844ce7e58c2c5466303f8d6e8adf031d0f19 Mon Sep 17 00:00:00 2001 From: James McIntosh Date: Thu, 30 Sep 2021 14:25:34 +1300 Subject: [PATCH 335/364] [google_maps_flutter_web] Add Marker drag events (#4385) --- .../google_maps_flutter_web/CHANGELOG.md | 4 +++ .../google_maps_controller_test.mocks.dart | 7 ++--- .../google_maps_plugin_test.dart | 22 +++++++++++++++ .../google_maps_plugin_test.mocks.dart | 7 ++--- .../example/integration_test/marker_test.dart | 28 +++++++++++++++++++ .../example/pubspec.yaml | 2 +- .../lib/src/google_maps_flutter_web.dart | 10 +++++++ .../lib/src/marker.dart | 18 ++++++++++++ .../lib/src/markers.dart | 22 +++++++++++++++ .../google_maps_flutter_web/pubspec.yaml | 4 +-- 10 files changed, 111 insertions(+), 13 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 4d7ecf74e098..b2fe086f5c9d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.2 + +Add `onDragStart` and `onDrag` to `Marker` + ## 0.3.1 * Fix the `getScreenCoordinate(LatLng)` method. [#80710](https://github.com/flutter/flutter/issues/80710) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart index af8ed5420a0c..530707c6c328 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart @@ -1,8 +1,4 @@ -// Copyright 2013 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. - -// Mocks generated by Mockito 5.0.15 from annotations +// Mocks generated by Mockito 5.0.16 from annotations // in google_maps_flutter_web_integration_tests/integration_test/google_maps_controller_test.dart. // Do not manually edit this file. @@ -19,6 +15,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types class _FakeGMap_0 extends _i1.Fake implements _i2.GMap {} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart index 758294f5bb91..a3cf86e593fe 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart @@ -404,6 +404,28 @@ void main() { await _testStreamFiltering(stream, event); }); + testWidgets('onMarkerDragStart', (WidgetTester tester) async { + final event = MarkerDragStartEvent( + mapId, + LatLng(43.3677, -5.8372), + MarkerId('test-123'), + ); + + final stream = plugin.onMarkerDragStart(mapId: mapId); + + await _testStreamFiltering(stream, event); + }); + testWidgets('onMarkerDrag', (WidgetTester tester) async { + final event = MarkerDragEvent( + mapId, + LatLng(43.3677, -5.8372), + MarkerId('test-123'), + ); + + final stream = plugin.onMarkerDrag(mapId: mapId); + + await _testStreamFiltering(stream, event); + }); testWidgets('onMarkerDragEnd', (WidgetTester tester) async { final event = MarkerDragEndEvent( mapId, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart index 01908ce777e7..d2df11c6ffa9 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart @@ -1,8 +1,4 @@ -// Copyright 2013 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. - -// Mocks generated by Mockito 5.0.15 from annotations +// Mocks generated by Mockito 5.0.16 from annotations // in google_maps_flutter_web_integration_tests/integration_test/google_maps_plugin_test.dart. // Do not manually edit this file. @@ -20,6 +16,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types class _FakeStreamController_0 extends _i1.Fake implements _i2.StreamController {} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart index 2bfa27b73a77..cfa36febbbfe 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart @@ -27,6 +27,14 @@ void main() { _methodCalledCompleter.complete(true); } + void onDragStart(gmaps.LatLng _) { + _methodCalledCompleter.complete(true); + } + + void onDrag(gmaps.LatLng _) { + _methodCalledCompleter.complete(true); + } + void onDragEnd(gmaps.LatLng _) { _methodCalledCompleter.complete(true); } @@ -53,6 +61,26 @@ void main() { expect(await methodCalled, isTrue); }); + testWidgets('onDragStart gets called', (WidgetTester tester) async { + MarkerController(marker: marker, onDragStart: onDragStart); + + // Trigger a drag end event... + gmaps.Event.trigger(marker, 'dragstart', + [gmaps.MapMouseEvent()..latLng = gmaps.LatLng(0, 0)]); + + expect(await methodCalled, isTrue); + }); + + testWidgets('onDrag gets called', (WidgetTester tester) async { + MarkerController(marker: marker, onDrag: onDrag); + + // Trigger a drag end event... + gmaps.Event.trigger( + marker, 'drag', [gmaps.MapMouseEvent()..latLng = gmaps.LatLng(0, 0)]); + + expect(await methodCalled, isTrue); + }); + testWidgets('onDragEnd gets called', (WidgetTester tester) async { MarkerController(marker: marker, onDragEnd: onDragEnd); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml index 249b893d198c..95a3d4253440 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: none # Tests require flutter beta or greater to run. environment: sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.1.0" + flutter: ">=2.1.0" dependencies: google_maps_flutter_web: diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart index d03dec93ce3f..47bfdc7bba15 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart @@ -240,6 +240,16 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onMarkerDragStart({required int mapId}) { + return _events(mapId).whereType(); + } + + @override + Stream onMarkerDrag({required int mapId}) { + return _events(mapId).whereType(); + } + @override Stream onMarkerDragEnd({required int mapId}) { return _events(mapId).whereType(); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart index 5b0169b565e5..c4cd40f43323 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart @@ -19,6 +19,8 @@ class MarkerController { required gmaps.Marker marker, gmaps.InfoWindow? infoWindow, bool consumeTapEvents = false, + LatLngCallback? onDragStart, + LatLngCallback? onDrag, LatLngCallback? onDragEnd, ui.VoidCallback? onTap, }) : _marker = marker, @@ -29,6 +31,22 @@ class MarkerController { onTap.call(); }); } + if (onDragStart != null) { + marker.onDragstart.listen((event) { + if (marker != null) { + marker.position = event.latLng; + } + onDragStart.call(event.latLng ?? _nullGmapsLatLng); + }); + } + if (onDrag != null) { + marker.onDrag.listen((event) { + if (marker != null) { + marker.position = event.latLng; + } + onDrag.call(event.latLng ?? _nullGmapsLatLng); + }); + } if (onDragEnd != null) { marker.onDragend.listen((event) { if (marker != null) { diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart index b650b9bcf1c8..542a48bcb707 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart @@ -62,6 +62,12 @@ class MarkersController extends GeometryController { this.showMarkerInfoWindow(marker.markerId); _onMarkerTap(marker.markerId); }, + onDragStart: (gmaps.LatLng latLng) { + _onMarkerDragStart(marker.markerId, latLng); + }, + onDrag: (gmaps.LatLng latLng) { + _onMarkerDrag(marker.markerId, latLng); + }, onDragEnd: (gmaps.LatLng latLng) { _onMarkerDragEnd(marker.markerId, latLng); }, @@ -140,6 +146,22 @@ class MarkersController extends GeometryController { _streamController.add(InfoWindowTapEvent(mapId, markerId)); } + void _onMarkerDragStart(MarkerId markerId, gmaps.LatLng latLng) { + _streamController.add(MarkerDragStartEvent( + mapId, + _gmLatLngToLatLng(latLng), + markerId, + )); + } + + void _onMarkerDrag(MarkerId markerId, gmaps.LatLng latLng) { + _streamController.add(MarkerDragEvent( + mapId, + _gmLatLngToLatLng(latLng), + markerId, + )); + } + void _onMarkerDragEnd(MarkerId markerId, gmaps.LatLng latLng) { _streamController.add(MarkerDragEndEvent( mapId, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index 08eda25352c8..1f5fe4d96ccc 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.3.1 +version: 0.3.2 environment: sdk: ">=2.12.0 <3.0.0" @@ -21,7 +21,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - google_maps_flutter_platform_interface: ^2.0.1 + google_maps_flutter_platform_interface: ^2.1.2 google_maps: ^5.2.0 meta: ^1.3.0 sanitize_html: ^2.0.0 From 5e54355afd23c4acb551b4c8aa630c4a7edb061d Mon Sep 17 00:00:00 2001 From: ryoheiudagawa1995 <63101140+ryoheiudagawa1995@users.noreply.github.com> Date: Thu, 30 Sep 2021 23:28:07 +0900 Subject: [PATCH 336/364] [in_app_purchase] Fix in_app_purchase_android/README.md (#4363) --- packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md | 3 +++ packages/in_app_purchase/in_app_purchase_android/README.md | 2 +- packages/in_app_purchase/in_app_purchase_android/pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 0c75ae3cf819..a01eb9f8b70a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.1.5+1 + +* Fix a broken link in the README. ## 0.1.5 * Introduced the `SkuDetailsWrapper.introductoryPriceAmountMicros` field of the correct type (`int`) and deprecated the `SkuDetailsWrapper.introductoryPriceMicros` field. diff --git a/packages/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md index dcf5256e3bbc..d64fbfb8c49a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/README.md +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -24,6 +24,6 @@ If you would like to contribute to the plugin, check out our [contribution guide](https://github.com/flutter/plugins/blob/master/CONTRIBUTING.md). -[1]: ../in_app_purchase/in_app_purchase +[1]: https://pub.dev/packages/in_app_purchase [2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin [3]: https://pub.dev/packages/in_app_purchase_android/install diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 33f51cc70feb..d51abbb43edc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.5 +version: 0.1.5+1 environment: sdk: ">=2.12.0 <3.0.0" From 63eb67532a7a244b3c99a2b2f837f71441116920 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 30 Sep 2021 11:33:05 -0400 Subject: [PATCH 337/364] Add file_selector to the repo list (#4395) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2d8f4f502e17..b5b688ffbaef 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ These are the available plugins in this repository. |--------|-----|--------|------------|-------| | [camera](./packages/camera/) | [![pub package](https://img.shields.io/pub/v/camera.svg)](https://pub.dev/packages/camera) | [![pub points](https://badges.bar/camera/pub%20points)](https://pub.dev/packages/camera/score) | [![popularity](https://badges.bar/camera/popularity)](https://pub.dev/packages/camera/score) | [![likes](https://badges.bar/camera/likes)](https://pub.dev/packages/camera/score) | | [espresso](./packages/espresso/) | [![pub package](https://img.shields.io/pub/v/espresso.svg)](https://pub.dev/packages/espresso) | [![pub points](https://badges.bar/espresso/pub%20points)](https://pub.dev/packages/espresso/score) | [![popularity](https://badges.bar/espresso/popularity)](https://pub.dev/packages/espresso/score) | [![likes](https://badges.bar/espresso/likes)](https://pub.dev/packages/espresso/score) | +| [file_selector](./packages/file_selector/) | [![pub package](https://img.shields.io/pub/v/file_selector.svg)](https://pub.dev/packages/file_selector) | [![pub points](https://badges.bar/file_selector/pub%20points)](https://pub.dev/packages/file_selector/score) | [![popularity](https://badges.bar/file_selector/popularity)](https://pub.dev/packages/file_selector/score) | [![likes](https://badges.bar/file_selector/likes)](https://pub.dev/packages/file_selector/score) | | [flutter_plugin_android_lifecycle](./packages/flutter_plugin_android_lifecycle/) | [![pub package](https://img.shields.io/pub/v/flutter_plugin_android_lifecycle.svg)](https://pub.dev/packages/flutter_plugin_android_lifecycle) | [![pub points](https://badges.bar/flutter_plugin_android_lifecycle/pub%20points)](https://pub.dev/packages/flutter_plugin_android_lifecycle/score) | [![popularity](https://badges.bar/flutter_plugin_android_lifecycle/popularity)](https://pub.dev/packages/flutter_plugin_android_lifecycle/score) | [![likes](https://badges.bar/flutter_plugin_android_lifecycle/likes)](https://pub.dev/packages/flutter_plugin_android_lifecycle/score) | | [google_maps_flutter](./packages/google_maps_flutter) | [![pub package](https://img.shields.io/pub/v/google_maps_flutter.svg)](https://pub.dev/packages/google_maps_flutter) | [![pub points](https://badges.bar/google_maps_flutter/pub%20points)](https://pub.dev/packages/google_maps_flutter/score) | [![popularity](https://badges.bar/google_maps_flutter/popularity)](https://pub.dev/packages/google_maps_flutter/score) | [![likes](https://badges.bar/google_maps_flutter/likes)](https://pub.dev/packages/google_maps_flutter/score) | | [google_sign_in](./packages/google_sign_in/) | [![pub package](https://img.shields.io/pub/v/google_sign_in.svg)](https://pub.dev/packages/google_sign_in) | [![pub points](https://badges.bar/google_sign_in/pub%20points)](https://pub.dev/packages/google_sign_in/score) | [![popularity](https://badges.bar/google_sign_in/popularity)](https://pub.dev/packages/google_sign_in/score) | [![likes](https://badges.bar/google_sign_in/likes)](https://pub.dev/packages/google_sign_in/score) | From cf40966ef1dcb7bbc0a0e1f83cd31c017daf1ea2 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Thu, 30 Sep 2021 13:30:26 -0400 Subject: [PATCH 338/364] [flutter_plugin_tools] Validate pubspec description (#4396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pub.dev deducts points for having a pubspec.yaml `description` that is too short or too long; several of our plugins are losing points on this. To ensure that we are following—and modeling—best practices, this adds a check that our `description` fields meet pub.dev expectations. Fixes our existing violations. Two are not published even though this only takes effect once published: - camera: We change this plugin pretty frequently, so this should go out soon without adding a release just for this trivial issue. - wifi_info_flutter: This is deprecated, so we don't plan to release it. It has to be fixed to allow the tool change to land though. --- packages/camera/camera/CHANGELOG.md | 4 + packages/camera/camera/pubspec.yaml | 6 +- packages/espresso/CHANGELOG.md | 3 +- packages/espresso/pubspec.yaml | 3 +- .../file_selector/file_selector/CHANGELOG.md | 5 +- .../file_selector/file_selector/pubspec.yaml | 5 +- .../plugin_platform_interface/CHANGELOG.md | 4 + .../plugin_platform_interface/pubspec.yaml | 5 +- .../wifi_info_flutter/CHANGELOG.md | 1 + .../wifi_info_flutter/pubspec.yaml | 2 +- script/tool/CHANGELOG.md | 2 + .../lib/src/common/repository_package.dart | 9 ++ .../tool/lib/src/pubspec_check_command.dart | 33 +++++++ .../test/common/repository_package_test.dart | 35 +++++++ .../tool/test/pubspec_check_command_test.dart | 93 +++++++++++++++++++ 15 files changed, 198 insertions(+), 12 deletions(-) diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index b2dda9a52436..c9dfc63eb46c 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updated package description. + ## 0.9.4+1 * Fixed Android implementation throwing IllegalStateException when switching to a different activity. diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 5c225eaee48f..21892b213781 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -1,7 +1,7 @@ name: camera -description: A Flutter plugin for getting information about and controlling the - camera on Android, iOS and Web. Supports previewing the camera feed, capturing images, capturing video, - and streaming image buffers to dart. +description: A Flutter plugin for controlling the camera. Supports previewing + the camera feed, capturing images and video, and streaming image buffers to + Dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 version: 0.9.4+1 diff --git a/packages/espresso/CHANGELOG.md b/packages/espresso/CHANGELOG.md index e00ea7065ce0..88976c88b668 100644 --- a/packages/espresso/CHANGELOG.md +++ b/packages/espresso/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.1.0+4 * Updated Android lint settings. +* Updated package description. ## 0.1.0+3 diff --git a/packages/espresso/pubspec.yaml b/packages/espresso/pubspec.yaml index 6295c0ce9694..c0f3b00d556c 100644 --- a/packages/espresso/pubspec.yaml +++ b/packages/espresso/pubspec.yaml @@ -1,8 +1,9 @@ name: espresso description: Java classes for testing Flutter apps using Espresso. + Allows driving Flutter widgets from a native Espresso test. repository: https://github.com/flutter/plugins/tree/master/packages/espresso issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+espresso%22 -version: 0.1.0+3 +version: 0.1.0+4 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/file_selector/file_selector/CHANGELOG.md b/packages/file_selector/file_selector/CHANGELOG.md index 225f601c3ee6..f34ed78d4e7f 100644 --- a/packages/file_selector/file_selector/CHANGELOG.md +++ b/packages/file_selector/file_selector/CHANGELOG.md @@ -1,10 +1,11 @@ -## NEXT +## 0.8.2+1 * Minor code cleanup for new analysis rules. +* Updated package description. ## 0.8.2 -* Update platform_plugin_interface version requirement. +* Update `platform_plugin_interface` version requirement. ## 0.8.1 diff --git a/packages/file_selector/file_selector/pubspec.yaml b/packages/file_selector/file_selector/pubspec.yaml index e1725ef05b93..d69217f208ef 100644 --- a/packages/file_selector/file_selector/pubspec.yaml +++ b/packages/file_selector/file_selector/pubspec.yaml @@ -1,8 +1,9 @@ name: file_selector -description: Flutter plugin for opening and saving files. +description: Flutter plugin for opening and saving files, or selecting + directories, using native file selection UI. repository: https://github.com/flutter/plugins/tree/master/packages/file_selector/file_selector issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+file_selector%22 -version: 0.8.2 +version: 0.8.2+1 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/plugin_platform_interface/CHANGELOG.md b/packages/plugin_platform_interface/CHANGELOG.md index 987049c55996..af79d119c5f6 100644 --- a/packages/plugin_platform_interface/CHANGELOG.md +++ b/packages/plugin_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.2 + +* Update package description. + ## 2.0.1 * Fix `federated flutter plugins` link in the README.md. diff --git a/packages/plugin_platform_interface/pubspec.yaml b/packages/plugin_platform_interface/pubspec.yaml index 66527bc58a61..0b4b1782b526 100644 --- a/packages/plugin_platform_interface/pubspec.yaml +++ b/packages/plugin_platform_interface/pubspec.yaml @@ -1,5 +1,6 @@ name: plugin_platform_interface -description: Reusable base class for Flutter plugin platform interfaces. +description: Reusable base class for platform interfaces of Flutter federated + plugins, to help enforce best practices. repository: https://github.com/flutter/plugins/tree/master/packages/plugin_platform_interface issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+plugin_platform_interface%22 @@ -14,7 +15,7 @@ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+ # be done when absolutely necessary and after the ecosystem has already migrated to 1.X.Y version # that is forward compatible with 2.0.0 (ideally the ecosystem have migrated to depend on: # `plugin_platform_interface: >=1.X.Y <3.0.0`). -version: 2.0.1 +version: 2.0.2 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md b/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md index 86f3f67af103..3d5599743710 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md +++ b/packages/wifi_info_flutter/wifi_info_flutter/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Updated Android lint settings. +* Updated package description. ## 2.0.2 diff --git a/packages/wifi_info_flutter/wifi_info_flutter/pubspec.yaml b/packages/wifi_info_flutter/wifi_info_flutter/pubspec.yaml index cbda364aa35e..b1e1e756c633 100644 --- a/packages/wifi_info_flutter/wifi_info_flutter/pubspec.yaml +++ b/packages/wifi_info_flutter/wifi_info_flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: wifi_info_flutter -description: A new flutter plugin project. +description: A Flutter plugin to get WiFi information such as connection status and network identifiers. repository: https://github.com/flutter/plugins/tree/master/packages/wifi_info_flutter/wifi_info_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+wifi_info_flutter%22 version: 2.0.2 diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 6119545260aa..2e6404e2cee4 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -10,6 +10,8 @@ major version change restriction. - Improved error handling and error messages in CHANGELOG version checks. - `license-check` now validates Kotlin files. +- `pubspec-check` now checks that the description is of the pub-recommended + length. ## 0.7.1 diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart index cb586afb4dfe..3b4417ac8182 100644 --- a/script/tool/lib/src/common/repository_package.dart +++ b/script/tool/lib/src/common/repository_package.dart @@ -58,6 +58,15 @@ class RepositoryPackage { bool get isPlatformInterface => directory.basename.endsWith('_platform_interface'); + /// True if this appears to be a platform implementation package, according to + /// repository conventions. + bool get isPlatformImplementation => + // Any part of a federated plugin that isn't the platform interface and + // isn't the app-facing package should be an implementation package. + isFederated && + !isPlatformInterface && + directory.basename != directory.parent.basename; + /// Returns the Flutter example packages contained in the package, if any. Iterable getExamples() { final Directory exampleDirectory = directory.childDirectory('example'); diff --git a/script/tool/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index fec0dcef9ac7..b99f5af68c45 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -126,6 +126,18 @@ class PubspecCheckCommand extends PackageLoopingCommand { '${indentation * 2}$_expectedIssueLinkFormat'); passing = false; } + + // Don't check descriptions for federated package components other than + // the app-facing package, since they are unlisted, and are expected to + // have short descriptions. + if (!package.isPlatformInterface && !package.isPlatformImplementation) { + final String? descriptionError = + _checkDescription(pubspec, package: package); + if (descriptionError != null) { + printError('$indentation$descriptionError'); + passing = false; + } + } } return passing; @@ -180,6 +192,27 @@ class PubspecCheckCommand extends PackageLoopingCommand { return errorMessages; } + // Validates the "description" field for a package, returning an error + // string if there are any issues. + String? _checkDescription( + Pubspec pubspec, { + required RepositoryPackage package, + }) { + final String? description = pubspec.description; + if (description == null) { + return 'Missing "description"'; + } + + if (description.length < 60) { + return '"description" is too short. pub.dev recommends package ' + 'descriptions of 60-180 characters.'; + } + if (description.length > 180) { + return '"description" is too long. pub.dev recommends package ' + 'descriptions of 60-180 characters.'; + } + } + bool _checkIssueLink(Pubspec pubspec) { return pubspec.issueTracker ?.toString() diff --git a/script/tool/test/common/repository_package_test.dart b/script/tool/test/common/repository_package_test.dart index 5c5624312f51..4c20389ae4be 100644 --- a/script/tool/test/common/repository_package_test.dart +++ b/script/tool/test/common/repository_package_test.dart @@ -120,4 +120,39 @@ void main() { plugin.childDirectory('example').childDirectory('example2').path); }); }); + + group('federated plugin queries', () { + test('all return false for a simple plugin', () { + final Directory plugin = createFakePlugin('a_plugin', packagesDir); + expect(RepositoryPackage(plugin).isFederated, false); + expect(RepositoryPackage(plugin).isPlatformInterface, false); + expect(RepositoryPackage(plugin).isFederated, false); + }); + + test('handle app-facing packages', () { + final Directory plugin = + createFakePlugin('a_plugin', packagesDir.childDirectory('a_plugin')); + expect(RepositoryPackage(plugin).isFederated, true); + expect(RepositoryPackage(plugin).isPlatformInterface, false); + expect(RepositoryPackage(plugin).isPlatformImplementation, false); + }); + + test('handle platform interface packages', () { + final Directory plugin = createFakePlugin('a_plugin_platform_interface', + packagesDir.childDirectory('a_plugin')); + expect(RepositoryPackage(plugin).isFederated, true); + expect(RepositoryPackage(plugin).isPlatformInterface, true); + expect(RepositoryPackage(plugin).isPlatformImplementation, false); + }); + + test('handle platform implementation packages', () { + // A platform interface can end with anything, not just one of the known + // platform names, because of cases like webview_flutter_wkwebview. + final Directory plugin = createFakePlugin( + 'a_plugin_foo', packagesDir.childDirectory('a_plugin')); + expect(RepositoryPackage(plugin).isFederated, true); + expect(RepositoryPackage(plugin).isPlatformInterface, false); + expect(RepositoryPackage(plugin).isPlatformImplementation, true); + }); + }); } diff --git a/script/tool/test/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 948136993d18..d09dcebce4af 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -56,6 +56,7 @@ void main() { bool includeHomepage = false, bool includeIssueTracker = true, bool publishable = true, + String? description, }) { final String repositoryPath = repositoryPackagesDirRelativePath ?? name; final String repoLink = 'https://github.com/flutter/' @@ -64,8 +65,11 @@ void main() { final String issueTrackerLink = 'https://github.com/flutter/flutter/issues?' 'q=is%3Aissue+is%3Aopen+label%3A%22p%3A+$name%22'; + description ??= 'A test package for validating that the pubspec.yaml ' + 'follows repo best practices.'; return ''' name: $name +description: $description ${includeRepository ? 'repository: $repoLink' : ''} ${includeHomepage ? 'homepage: $repoLink' : ''} ${includeIssueTracker ? 'issue_tracker: $issueTrackerLink' : ''} @@ -327,6 +331,95 @@ ${devDependenciesSection()} ); }); + test('fails when description is too short', () async { + final Directory pluginDirectory = + createFakePlugin('a_plugin', packagesDir.childDirectory('a_plugin')); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin', isPlugin: true, description: 'Too short')} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('"description" is too short. pub.dev recommends package ' + 'descriptions of 60-180 characters.'), + ]), + ); + }); + + test( + 'allows short descriptions for non-app-facing parts of federated plugins', + () async { + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); + + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin', isPlugin: true, description: 'Too short')} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('"description" is too short. pub.dev recommends package ' + 'descriptions of 60-180 characters.'), + ]), + ); + }); + + test('fails when description is too long', () async { + final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); + + const String description = 'This description is too long. It just goes ' + 'on and on and on and on and on. pub.dev will down-score it because ' + 'there is just too much here. Someone shoul really cut this down to just ' + 'the core description so that search results are more useful and the ' + 'package does not lose pub points.'; + pluginDirectory.childFile('pubspec.yaml').writeAsStringSync(''' +${headerSection('plugin', isPlugin: true, description: description)} +${environmentSection()} +${flutterSection(isPlugin: true)} +${dependenciesSection()} +${devDependenciesSection()} +'''); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['pubspec-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('"description" is too long. pub.dev recommends package ' + 'descriptions of 60-180 characters.'), + ]), + ); + }); + test('fails when environment section is out of order', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir); From f6d93a765063befe13cab7fe6b1ac17104ff9f4e Mon Sep 17 00:00:00 2001 From: James McIntosh Date: Fri, 1 Oct 2021 09:02:17 +1300 Subject: [PATCH 339/364] [google_maps_flutter] Add Marker drag events (#2838) --- .../google_maps_flutter/CHANGELOG.md | 4 + .../googlemaps/GoogleMapController.java | 8 +- .../plugins/googlemaps/MarkersController.java | 22 +++ .../googlemaps/MarkersControllerTest.java | 131 +++++++++++++++ .../example/lib/drag_marker.dart | 156 ++++++++++++++++++ .../google_maps_flutter/example/lib/main.dart | 2 + .../example/lib/place_marker.dart | 2 +- .../ios/Classes/GoogleMapController.m | 10 ++ .../ios/Classes/GoogleMapMarkerController.h | 2 + .../ios/Classes/GoogleMapMarkerController.m | 22 +++ .../lib/src/controller.dart | 6 + .../lib/src/google_map.dart | 26 ++- .../google_maps_flutter/pubspec.yaml | 4 +- .../test/map_creation_test.dart | 10 ++ 14 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java create mode 100644 packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 5406dc50e04a..0c95abda319b 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.11 + +* Add additional marker drag events. + ## 2.0.10 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java index 056e10631011..9b8810354b8f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java @@ -467,10 +467,14 @@ public boolean onMarkerClick(Marker marker) { } @Override - public void onMarkerDragStart(Marker marker) {} + public void onMarkerDragStart(Marker marker) { + markersController.onMarkerDragStart(marker.getId(), marker.getPosition()); + } @Override - public void onMarkerDrag(Marker marker) {} + public void onMarkerDrag(Marker marker) { + markersController.onMarkerDrag(marker.getId(), marker.getPosition()); + } @Override public void onMarkerDragEnd(Marker marker) { diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java index 189cba03c1cd..47ffe9b857d6 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java @@ -105,6 +105,28 @@ boolean onMarkerTap(String googleMarkerId) { return false; } + void onMarkerDragStart(String googleMarkerId, LatLng latLng) { + String markerId = googleMapsMarkerIdToDartMarkerId.get(googleMarkerId); + if (markerId == null) { + return; + } + final Map data = new HashMap<>(); + data.put("markerId", markerId); + data.put("position", Convert.latLngToJson(latLng)); + methodChannel.invokeMethod("marker#onDragStart", data); + } + + void onMarkerDrag(String googleMarkerId, LatLng latLng) { + String markerId = googleMapsMarkerIdToDartMarkerId.get(googleMarkerId); + if (markerId == null) { + return; + } + final Map data = new HashMap<>(); + data.put("markerId", markerId); + data.put("position", Convert.latLngToJson(latLng)); + methodChannel.invokeMethod("marker#onDrag", data); + } + void onMarkerDragEnd(String googleMarkerId, LatLng latLng) { String markerId = googleMapsMarkerIdToDartMarkerId.get(googleMarkerId); if (markerId == null) { diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java new file mode 100644 index 000000000000..afe91d77e2be --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java @@ -0,0 +1,131 @@ +// Copyright 2013 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. + +package io.flutter.plugins.googlemaps; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.google.android.gms.internal.maps.zzt; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodCodec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.mockito.Mockito; + +public class MarkersControllerTest { + + @Test + public void controller_OnMarkerDragStart() { + final MethodChannel methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + final MarkersController controller = new MarkersController(methodChannel); + final GoogleMap googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + + final zzt z = mock(zzt.class); + final Marker marker = new Marker(z); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final LatLng latLng = new LatLng(1.1, 2.2); + final Map markerOptions = new HashMap(); + markerOptions.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions); + controller.addMarkers(markers); + controller.onMarkerDragStart(googleMarkerId, latLng); + + final List points = new ArrayList(); + points.add(latLng.latitude); + points.add(latLng.longitude); + + final Map data = new HashMap<>(); + data.put("markerId", googleMarkerId); + data.put("position", points); + Mockito.verify(methodChannel).invokeMethod("marker#onDragStart", data); + } + + @Test + public void controller_OnMarkerDragEnd() { + final MethodChannel methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + final MarkersController controller = new MarkersController(methodChannel); + final GoogleMap googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + + final zzt z = mock(zzt.class); + final Marker marker = new Marker(z); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final LatLng latLng = new LatLng(1.1, 2.2); + final Map markerOptions = new HashMap(); + markerOptions.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions); + controller.addMarkers(markers); + controller.onMarkerDragEnd(googleMarkerId, latLng); + + final List points = new ArrayList(); + points.add(latLng.latitude); + points.add(latLng.longitude); + + final Map data = new HashMap<>(); + data.put("markerId", googleMarkerId); + data.put("position", points); + Mockito.verify(methodChannel).invokeMethod("marker#onDragEnd", data); + } + + @Test + public void controller_OnMarkerDrag() { + final MethodChannel methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + final MarkersController controller = new MarkersController(methodChannel); + final GoogleMap googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + + final zzt z = mock(zzt.class); + final Marker marker = new Marker(z); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final LatLng latLng = new LatLng(1.1, 2.2); + final Map markerOptions = new HashMap(); + markerOptions.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions); + controller.addMarkers(markers); + controller.onMarkerDrag(googleMarkerId, latLng); + + final List points = new ArrayList(); + points.add(latLng.latitude); + points.add(latLng.longitude); + + final Map data = new HashMap<>(); + data.put("markerId", googleMarkerId); + data.put("position", points); + Mockito.verify(methodChannel).invokeMethod("marker#onDrag", data); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart new file mode 100644 index 000000000000..2c7929115247 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart @@ -0,0 +1,156 @@ +// Copyright 2013 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. + +// ignore_for_file: public_member_api_docs + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'page.dart'; + +class DragMarkerPage extends GoogleMapExampleAppPage { + DragMarkerPage() : super(const Icon(Icons.drag_handle), 'Drag marker'); + + @override + Widget build(BuildContext context) { + return const DragMarkerBody(); + } +} + +class DragMarkerBody extends StatefulWidget { + const DragMarkerBody(); + + @override + State createState() => DragMarkerBodyState(); +} + +typedef MarkerUpdateAction = Marker Function(Marker marker); + +class DragMarkerBodyState extends State { + DragMarkerBodyState(); + static const LatLng center = LatLng(-33.86711, 151.1947171); + + GoogleMapController? controller; + Map markers = {}; + MarkerId? selectedMarker; + int _markerIdCounter = 1; + LatLng? markerPosition; + + void _onMapCreated(GoogleMapController controller) { + this.controller = controller; + } + + void _onMarkerTapped(MarkerId markerId) { + final Marker? tappedMarker = markers[markerId]; + if (tappedMarker != null) { + setState(() { + if (markers.containsKey(selectedMarker)) { + final Marker resetOld = markers[selectedMarker]! + .copyWith(iconParam: BitmapDescriptor.defaultMarker); + markers[selectedMarker!] = resetOld; + } + selectedMarker = markerId; + final Marker newMarker = tappedMarker.copyWith( + iconParam: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueGreen, + ), + ); + markers[markerId] = newMarker; + }); + } + } + + void _onMarkerDrag(MarkerId markerId, LatLng newPosition) async { + setState(() { + this.markerPosition = newPosition; + }); + } + + void _add() { + final int markerCount = markers.length; + + if (markerCount == 12) { + return; + } + + final String markerIdVal = 'marker_id_$_markerIdCounter'; + _markerIdCounter++; + final MarkerId markerId = MarkerId(markerIdVal); + + final Marker marker = Marker( + draggable: true, + markerId: markerId, + position: LatLng( + center.latitude + sin(_markerIdCounter * pi / 6.0) / 20.0, + center.longitude + cos(_markerIdCounter * pi / 6.0) / 20.0, + ), + infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), + onTap: () => _onMarkerTapped(markerId), + onDrag: (LatLng position) => _onMarkerDrag(markerId, position), + ); + + setState(() { + markers[markerId] = marker; + }); + } + + void _remove() { + setState(() { + if (markers.containsKey(selectedMarker)) { + markers.remove(selectedMarker); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Center( + child: GoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: center, + zoom: 11.0, + ), + markers: markers.values.toSet(), + ), + ), + ), + Container( + height: 30, + padding: EdgeInsets.only(left: 12, right: 12), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + markerPosition == null + ? Container() + : Expanded(child: Text("lat: ${markerPosition!.latitude}")), + markerPosition == null + ? Container() + : Expanded(child: Text("lng: ${markerPosition!.longitude}")), + ], + ), + ), + Row( + children: [ + TextButton( + child: const Text('add'), + onPressed: _add, + ), + TextButton( + child: const Text('remove'), + onPressed: _remove, + ), + ], + ), + ], + ); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart index 15b14db0357a..16f242c9e0ce 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart @@ -5,6 +5,7 @@ // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; +import 'package:google_maps_flutter_example/drag_marker.dart'; import 'package:google_maps_flutter_example/lite_mode.dart'; import 'animate_camera.dart'; import 'map_click.dart'; @@ -34,6 +35,7 @@ final List _allPages = [ PlacePolylinePage(), PlacePolygonPage(), PlaceCirclePage(), + DragMarkerPage(), PaddingPage(), SnapshotPage(), LiteModePage(), diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart index 3d083e5f9fa9..53f553eb67f8 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart @@ -30,7 +30,7 @@ class PlaceMarkerBody extends StatefulWidget { State createState() => PlaceMarkerBodyState(); } -typedef Marker MarkerUpdateAction(Marker marker); +typedef MarkerUpdateAction = Marker Function(Marker marker); class PlaceMarkerBodyState extends State { PlaceMarkerBodyState(); diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m index be3728753a5d..1428de69885b 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m @@ -509,6 +509,16 @@ - (void)mapView:(GMSMapView*)mapView didEndDraggingMarker:(GMSMarker*)marker { [_markersController onMarkerDragEnd:markerId coordinate:marker.position]; } +- (void)mapView:(GMSMapView*)mapView didStartDraggingMarker:(GMSMarker*)marker { + NSString* markerId = marker.userData[0]; + [_markersController onMarkerDragStart:markerId coordinate:marker.position]; +} + +- (void)mapView:(GMSMapView*)mapView didDragMarker:(GMSMarker*)marker { + NSString* markerId = marker.userData[0]; + [_markersController onMarkerDrag:markerId coordinate:marker.position]; +} + - (void)mapView:(GMSMapView*)mapView didTapInfoWindowOfMarker:(GMSMarker*)marker { NSString* markerId = marker.userData[0]; [_markersController onInfoWindowTap:markerId]; diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h index d3e835435ed9..56251872c4fb 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h @@ -45,7 +45,9 @@ NS_ASSUME_NONNULL_BEGIN - (void)changeMarkers:(NSArray*)markersToChange; - (void)removeMarkerIds:(NSArray*)markerIdsToRemove; - (BOOL)onMarkerTap:(NSString*)markerId; +- (void)onMarkerDragStart:(NSString*)markerId coordinate:(CLLocationCoordinate2D)coordinate; - (void)onMarkerDragEnd:(NSString*)markerId coordinate:(CLLocationCoordinate2D)coordinate; +- (void)onMarkerDrag:(NSString*)markerId coordinate:(CLLocationCoordinate2D)coordinate; - (void)onInfoWindowTap:(NSString*)markerId; - (void)showMarkerInfoWindow:(NSString*)markerId result:(FlutterResult)result; - (void)hideMarkerInfoWindow:(NSString*)markerId result:(FlutterResult)result; diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m index 6a9fb885afac..51ed825fa7d7 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m @@ -295,6 +295,28 @@ - (BOOL)onMarkerTap:(NSString*)markerId { [_methodChannel invokeMethod:@"marker#onTap" arguments:@{@"markerId" : markerId}]; return controller.consumeTapEvents; } +- (void)onMarkerDragStart:(NSString*)markerId coordinate:(CLLocationCoordinate2D)coordinate { + if (!markerId) { + return; + } + FLTGoogleMapMarkerController* controller = _markerIdToController[markerId]; + if (!controller) { + return; + } + [_methodChannel invokeMethod:@"marker#onDragStart" + arguments:@{@"markerId" : markerId, @"position" : PositionToJson(coordinate)}]; +} +- (void)onMarkerDrag:(NSString*)markerId coordinate:(CLLocationCoordinate2D)coordinate { + if (!markerId) { + return; + } + FLTGoogleMapMarkerController* controller = _markerIdToController[markerId]; + if (!controller) { + return; + } + [_methodChannel invokeMethod:@"marker#onDrag" + arguments:@{@"markerId" : markerId, @"position" : PositionToJson(coordinate)}]; +} - (void)onMarkerDragEnd:(NSString*)markerId coordinate:(CLLocationCoordinate2D)coordinate { if (!markerId) { return; diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart index ba18c5ffc17b..d57ac97b1663 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart @@ -69,6 +69,12 @@ class GoogleMapController { GoogleMapsFlutterPlatform.instance .onMarkerTap(mapId: mapId) .listen((MarkerTapEvent e) => _googleMapState.onMarkerTap(e.value)); + GoogleMapsFlutterPlatform.instance.onMarkerDragStart(mapId: mapId).listen( + (MarkerDragStartEvent e) => + _googleMapState.onMarkerDragStart(e.value, e.position)); + GoogleMapsFlutterPlatform.instance.onMarkerDrag(mapId: mapId).listen( + (MarkerDragEvent e) => + _googleMapState.onMarkerDrag(e.value, e.position)); GoogleMapsFlutterPlatform.instance.onMarkerDragEnd(mapId: mapId).listen( (MarkerDragEndEvent e) => _googleMapState.onMarkerDragEnd(e.value, e.position)); diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart index 26b9d6b83c84..b4ffd22360e8 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart @@ -8,7 +8,7 @@ part of google_maps_flutter; /// /// Pass to [GoogleMap.onMapCreated] to receive a [GoogleMapController] when the /// map is created. -typedef void MapCreatedCallback(GoogleMapController controller); +typedef MapCreatedCallback = void Function(GoogleMapController controller); // This counter is used to provide a stable "constant" initialization id // to the buildView function, so the web implementation can use it as a @@ -368,6 +368,30 @@ class _GoogleMapState extends State { } } + void onMarkerDragStart(MarkerId markerId, LatLng position) { + assert(markerId != null); + final Marker? marker = _markers[markerId]; + if (marker == null) { + throw UnknownMapObjectIdError('marker', markerId, 'onDragStart'); + } + final ValueChanged? onDragStart = marker.onDragStart; + if (onDragStart != null) { + onDragStart(position); + } + } + + void onMarkerDrag(MarkerId markerId, LatLng position) { + assert(markerId != null); + final Marker? marker = _markers[markerId]; + if (marker == null) { + throw UnknownMapObjectIdError('marker', markerId, 'onDrag'); + } + final ValueChanged? onDrag = marker.onDrag; + if (onDrag != null) { + onDrag(position); + } + } + void onMarkerDragEnd(MarkerId markerId, LatLng position) { assert(markerId != null); final Marker? marker = _markers[markerId]; diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 641e475a56f0..61ac88a8f10d 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.0.10 +version: 2.0.11 environment: sdk: ">=2.14.0 <3.0.0" @@ -21,7 +21,7 @@ dependencies: flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.1 - google_maps_flutter_platform_interface: ^2.0.0 + google_maps_flutter_platform_interface: ^2.1.2 dev_dependencies: flutter_test: diff --git a/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart index 6e0f5ed3e4f5..6b3ac906802f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart @@ -229,6 +229,16 @@ class TestGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { return mapEventStreamController.stream.whereType(); } + @override + Stream onMarkerDragStart({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + + @override + Stream onMarkerDrag({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override Stream onMarkerDragEnd({required int mapId}) { return mapEventStreamController.stream.whereType(); From d9490e76efdd71bf7f2d88fe85e66bd4ab5426f8 Mon Sep 17 00:00:00 2001 From: Murray-Meller Date: Sat, 2 Oct 2021 02:48:05 +1000 Subject: [PATCH 340/364] [google_maps_flutter]: LatLng longitude loses precision in constructor #90574 (#4374) --- .../CHANGELOG.md | 5 ++ .../lib/src/types/location.dart | 7 ++- .../pubspec.yaml | 2 +- .../test/types/location_test.dart | 62 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md index 464c33ed754e..3a22dde8b659 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.1.3 + +* `LatLng` constructor maintains longitude precision when given within + acceptable range + ## 2.1.2 * Add additional marker drag events diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart index 42c66e036fd7..7a1aaf051388 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart @@ -14,13 +14,16 @@ class LatLng { /// The latitude is clamped to the inclusive interval from -90.0 to +90.0. /// /// The longitude is normalized to the half-open interval from -180.0 - /// (inclusive) to +180.0 (exclusive) + /// (inclusive) to +180.0 (exclusive). const LatLng(double latitude, double longitude) : assert(latitude != null), assert(longitude != null), latitude = (latitude < -90.0 ? -90.0 : (90.0 < latitude ? 90.0 : latitude)), - longitude = (longitude + 180.0) % 360.0 - 180.0; + // Avoids normalization if possible to prevent unnecessary loss of precision + longitude = longitude >= -180 && longitude < 180 + ? longitude + : (longitude + 180.0) % 360.0 - 180.0; /// The latitude in degrees between -90.0 and 90.0, both inclusive. final double latitude; diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml index 2a2c9cfa8b46..d3d7653b746e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.1.2 +version: 2.1.3 environment: sdk: '>=2.12.0 <3.0.0' diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart new file mode 100644 index 000000000000..80f696177dfd --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart @@ -0,0 +1,62 @@ +// Copyright 2013 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 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LanLng constructor', () { + test('Maintains longitude precision if within acceptable range', () async { + const lat = -34.509981; + const lng = 150.792384; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(lng)); + }); + + test('Normalizes longitude that is below lower limit', () async { + const lat = -34.509981; + const lng = -270.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(90.0)); + }); + + test('Normalizes longitude that is above upper limit', () async { + const lat = -34.509981; + const lng = 270.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(-90.0)); + }); + + test('Includes longitude set to lower limit', () async { + const lat = -34.509981; + const lng = -180.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(-180.0)); + }); + + test('Normalizes longitude set to upper limit', () async { + const lat = -34.509981; + const lng = 180.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(-180.0)); + }); + }); +} From a12791cbeb4e0a855c473ed29c448fb4e9fd50f3 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 1 Oct 2021 16:28:06 -0400 Subject: [PATCH 341/364] Fix order-dependant platform interface tests (#4406) --- .../test/file_selector_platform_interface_test.dart | 6 ++++-- .../google_maps_flutter_platform_test.dart | 7 +++++-- .../test/google_sign_in_platform_interface_test.dart | 5 ++++- .../test/quick_actions_platform_interface_test.dart | 5 ++++- .../test/method_channel_url_launcher_test.dart | 6 ++++-- .../test/method_channel_video_player_test.dart | 6 ++++-- 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart b/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart index a5b22b977d69..91e78b452961 100644 --- a/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart +++ b/packages/file_selector/file_selector_platform_interface/test/file_selector_platform_interface_test.dart @@ -7,10 +7,12 @@ import 'package:file_selector_platform_interface/src/method_channel/method_chann import 'package:flutter_test/flutter_test.dart'; void main() { + // Store the initial instance before any tests change it. + final FileSelectorPlatform initialInstance = FileSelectorPlatform.instance; + group('$FileSelectorPlatform', () { test('$MethodChannelFileSelector() is the default instance', () { - expect(FileSelectorPlatform.instance, - isInstanceOf()); + expect(initialInstance, isInstanceOf()); }); test('Can be extended', () { diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart index de4edf375696..c381f9e30750 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/platform_interface/google_maps_flutter_platform_test.dart @@ -16,10 +16,13 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Store the initial instance before any tests change it. + final GoogleMapsFlutterPlatform initialInstance = + GoogleMapsFlutterPlatform.instance; + group('$GoogleMapsFlutterPlatform', () { test('$MethodChannelGoogleMapsFlutter() is the default instance', () { - expect(GoogleMapsFlutterPlatform.instance, - isInstanceOf()); + expect(initialInstance, isInstanceOf()); }); test('Cannot be implemented with `implements`', () { diff --git a/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart b/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart index b3ac51b7fa52..a3450b6f3fb9 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart @@ -7,9 +7,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; void main() { + // Store the initial instance before any tests change it. + final GoogleSignInPlatform initialInstance = GoogleSignInPlatform.instance; + group('$GoogleSignInPlatform', () { test('$MethodChannelGoogleSignIn is the default instance', () { - expect(GoogleSignInPlatform.instance, isA()); + expect(initialInstance, isA()); }); test('Cannot be implemented with `implements`', () { diff --git a/packages/quick_actions/quick_actions_platform_interface/test/quick_actions_platform_interface_test.dart b/packages/quick_actions/quick_actions_platform_interface/test/quick_actions_platform_interface_test.dart index 8ce40816217f..d1c8798aa65f 100644 --- a/packages/quick_actions/quick_actions_platform_interface/test/quick_actions_platform_interface_test.dart +++ b/packages/quick_actions/quick_actions_platform_interface/test/quick_actions_platform_interface_test.dart @@ -9,9 +9,12 @@ import 'package:quick_actions_platform_interface/platform_interface/quick_action void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Store the initial instance before any tests change it. + final QuickActionsPlatform initialInstance = QuickActionsPlatform.instance; + group('$QuickActionsPlatform', () { test('$MethodChannelQuickActions is the default instance', () { - expect(QuickActionsPlatform.instance, isA()); + expect(initialInstance, isA()); }); test('Cannot be implemented with `implements`', () { diff --git a/packages/url_launcher/url_launcher_platform_interface/test/method_channel_url_launcher_test.dart b/packages/url_launcher/url_launcher_platform_interface/test/method_channel_url_launcher_test.dart index 5bfc78c5c5a2..23d9a4534f7b 100644 --- a/packages/url_launcher/url_launcher_platform_interface/test/method_channel_url_launcher_test.dart +++ b/packages/url_launcher/url_launcher_platform_interface/test/method_channel_url_launcher_test.dart @@ -14,10 +14,12 @@ import 'package:url_launcher_platform_interface/url_launcher_platform_interface. void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Store the initial instance before any tests change it. + final UrlLauncherPlatform initialInstance = UrlLauncherPlatform.instance; + group('$UrlLauncherPlatform', () { test('$MethodChannelUrlLauncher() is the default instance', () { - expect(UrlLauncherPlatform.instance, - isInstanceOf()); + expect(initialInstance, isInstanceOf()); }); test('Cannot be implemented with `implements`', () { diff --git a/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart b/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart index 9da71617e66a..f5439b844045 100644 --- a/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart +++ b/packages/video_player/video_player_platform_interface/test/method_channel_video_player_test.dart @@ -92,10 +92,12 @@ class _ApiLogger implements TestHostVideoPlayerApi { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Store the initial instance before any tests change it. + final VideoPlayerPlatform initialInstance = VideoPlayerPlatform.instance; + group('$VideoPlayerPlatform', () { test('$MethodChannelVideoPlayer() is the default instance', () { - expect(VideoPlayerPlatform.instance, - isInstanceOf()); + expect(initialInstance, isInstanceOf()); }); }); From dc7b8641174fc34a3c895e6fc17d88fbdd322cdd Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Sat, 2 Oct 2021 09:48:04 -0400 Subject: [PATCH 342/364] [ci] Remove obsolete Dockerfile (#4405) --- .ci/java8.Dockerfile | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .ci/java8.Dockerfile diff --git a/.ci/java8.Dockerfile b/.ci/java8.Dockerfile deleted file mode 100644 index fb1844a5401f..000000000000 --- a/.ci/java8.Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM cirrusci/flutter:stable - -RUN apt-get update -y - -# Required by Roboeletric and the Android SDK. -RUN apt-get install -y openjdk-8-jdk -ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 - -RUN apt-get install -y --no-install-recommends gnupg - -# Add repo for gcloud sdk and install it -RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | \ - tee -a /etc/apt/sources.list.d/google-cloud-sdk.list - -RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | \ - apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - - -RUN apt-get update && apt-get install -y google-cloud-sdk && \ - gcloud config set core/disable_usage_reporting true && \ - gcloud config set component_manager/disable_update_check true - -RUN yes | sdkmanager \ - "platforms;android-27" \ - "build-tools;27.0.3" \ - "extras;google;m2repository" \ - "extras;android;m2repository" - -RUN yes | sdkmanager --licenses - -# Install formatter. -RUN apt-get install -y clang-format -# Required by Roboeletric and the Android SDK. -RUN apt-get install -y openjdk-8-jdk From 0558169b893075b13dcea5dbb5b2dec2f714ee6f Mon Sep 17 00:00:00 2001 From: Nick Bradshaw Date: Tue, 5 Oct 2021 07:24:04 -0700 Subject: [PATCH 343/364] [webview_flutter] Add zoomEnabled to webview flutter platform interface (#4404) This is the first-step task to add a zoomEnabled param on the WebView widget. --- .../webview_flutter_platform_interface/CHANGELOG.md | 4 ++++ .../lib/src/method_channel/webview_method_channel.dart | 1 + .../lib/src/types/web_settings.dart | 4 ++++ .../webview_flutter_platform_interface/pubspec.yaml | 2 +- .../test/src/method_channel/webview_method_channel_test.dart | 3 ++- 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md index 9e217a04e961..93c470cee069 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.0 + +* Add `zoomEnabled` functionality to `WebSettings`. + ## 1.0.0 * Extracted platform interface from `webview_flutter`. \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart index b467daf72a08..411cad038618 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart @@ -200,6 +200,7 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { _addIfNonNull( 'allowsInlineMediaPlayback', settings.allowsInlineMediaPlayback); _addSettingIfPresent('userAgent', settings.userAgent); + _addIfNonNull('zoomEnabled', settings.zoomEnabled); return map; } diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart index 48b2de9c1ca0..3d94153c886e 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart @@ -78,6 +78,7 @@ class WebSettings { this.debuggingEnabled, this.gestureNavigationEnabled, this.allowsInlineMediaPlayback, + this.zoomEnabled, required this.userAgent, }) : assert(userAgent != null); @@ -111,6 +112,9 @@ class WebSettings { /// See also [WebView.userAgent]. final WebSetting userAgent; + /// Sets whether the WebView should support zooming using its on-screen zoom controls and gestures. + final bool? zoomEnabled; + /// Whether to allow swipe based navigation in iOS. /// /// See also: [WebView.gestureNavigationEnabled] diff --git a/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml index bf43c265d77a..166c55b133db 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/webview_flut issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview_flutter%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 1.0.0 +version: 1.1.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart index 2f845eaa4999..3914b2080259 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart @@ -5,7 +5,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; - import 'package:webview_flutter_platform_interface/src/method_channel/webview_method_channel.dart'; import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; @@ -229,6 +228,7 @@ void main() { debuggingEnabled: true, gestureNavigationEnabled: true, allowsInlineMediaPlayback: true, + zoomEnabled: false, ); await webViewPlatform.updateSettings(settings); @@ -245,6 +245,7 @@ void main() { 'debuggingEnabled': true, 'gestureNavigationEnabled': true, 'allowsInlineMediaPlayback': true, + 'zoomEnabled': false, }, ), ], From cd5dc3ba4c543b0fb79d0d44f3414d097ebbd9e6 Mon Sep 17 00:00:00 2001 From: BeMacized Date: Wed, 6 Oct 2021 10:28:02 +0200 Subject: [PATCH 344/364] [webview_flutter] Update webview platform interface with new methods for running JavaScript. (#4401) --- .../CHANGELOG.md | 4 ++ .../webview_method_channel.dart | 16 +++++++- .../webview_platform_controller.dart | 24 +++++++++-- .../lib/src/types/javascript_channel.dart | 6 +-- .../pubspec.yaml | 2 +- .../webview_method_channel_test.dart | 41 +++++++++++++++++-- 6 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md index 93c470cee069..04641f97dc79 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.2.0 + +* Added `runJavascript` and `runJavascriptReturningResult` interface methods to supersede `evaluateJavascript`. + ## 1.1.0 * Add `zoomEnabled` functionality to `WebSettings`. diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart index 411cad038618..9610038eec82 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart @@ -123,9 +123,21 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { } @override - Future evaluateJavascript(String javascriptString) { + Future evaluateJavascript(String javascript) { return _channel - .invokeMethod('evaluateJavascript', javascriptString) + .invokeMethod('evaluateJavascript', javascript) + .then((result) => result!); + } + + @override + Future runJavascript(String javascript) async { + await _channel.invokeMethod('runJavascript', javascript); + } + + @override + Future runJavascriptReturningResult(String javascript) { + return _channel + .invokeMethod('runJavascriptReturningResult', javascript) .then((result) => result!); } diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart index 319ca7e7a845..b42da4326079 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart @@ -106,12 +106,30 @@ abstract class WebViewPlatformController { /// Evaluates a JavaScript expression in the context of the current page. /// /// The Future completes with an error if a JavaScript error occurred, or if the type of the - /// evaluated expression is not supported(e.g on iOS not all non primitive type can be evaluated). - Future evaluateJavascript(String javascriptString) { + /// evaluated expression is not supported (e.g on iOS not all non-primitive types can be evaluated). + Future evaluateJavascript(String javascript) { throw UnimplementedError( "WebView evaluateJavascript is not implemented on the current platform"); } + /// Runs the given JavaScript in the context of the current page. + /// + /// The Future completes with an error if a JavaScript error occurred. + Future runJavascript(String javascript) { + throw UnimplementedError( + "WebView runJavascript is not implemented on the current platform"); + } + + /// Runs the given JavaScript in the context of the current page, and returns the result. + /// + /// The Future completes with an error if a JavaScript error occurred, or if the + /// type the given expression evaluates to is unsupported. Unsupported values include + /// certain non-primitive types on iOS, as well as `undefined` or `null` on iOS 14+. + Future runJavascriptReturningResult(String javascript) { + throw UnimplementedError( + "WebView runJavascriptReturningResult is not implemented on the current platform"); + } + /// Adds new JavaScript channels to the set of enabled channels. /// /// For each value in this list the platform's webview should make sure that a corresponding @@ -130,7 +148,7 @@ abstract class WebViewPlatformController { /// Removes JavaScript channel names from the set of enabled channels. /// - /// This disables channels that were previously enabled by [addJavaScriptChannels] or through + /// This disables channels that were previously enabled by [addJavascriptChannels] or through /// [CreationParams.javascriptChannelNames]. Future removeJavascriptChannels(Set javascriptChannelNames) { throw UnimplementedError( diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart index 8b31f5b6061e..f32a41893eb5 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart @@ -4,14 +4,14 @@ import 'javascript_message.dart'; -/// Callback type for handling messages sent from Javascript running in a web view. +/// Callback type for handling messages sent from JavaScript running in a web view. typedef void JavascriptMessageHandler(JavascriptMessage message); final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9_]*\$'); /// A named channel for receiving messaged from JavaScript code running inside a web view. class JavascriptChannel { - /// Constructs a Javascript channel. + /// Constructs a JavaScript channel. /// /// The parameters `name` and `onMessageReceived` must not be null. JavascriptChannel({ @@ -24,7 +24,7 @@ class JavascriptChannel { /// The channel's name. /// /// Passing this channel object as part of a [WebView.javascriptChannels] adds a channel object to - /// the Javascript window object's property named `name`. + /// the JavaScript window object's property named `name`. /// /// The name must start with a letter or underscore(_), followed by any combination of those /// characters plus digits. diff --git a/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml index 166c55b133db..994c3dcdebdf 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/webview_flut issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview_flutter%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 1.1.0 +version: 1.2.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart index 3914b2080259..85f184f9f715 100644 --- a/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart @@ -30,6 +30,7 @@ void main() { case 'canGoBack': case 'canGoForward': return true; + case 'runJavascriptReturningResult': case 'evaluateJavascript': return methodCall.arguments as String; case 'getScrollX': @@ -266,16 +267,50 @@ void main() { test('evaluateJavascript', () async { final String evaluateJavascript = await webViewPlatform.evaluateJavascript( - 'This simulates some Javascript code.', + 'This simulates some JavaScript code.', ); - expect('This simulates some Javascript code.', evaluateJavascript); + expect('This simulates some JavaScript code.', evaluateJavascript); expect( log, [ isMethodCall( 'evaluateJavascript', - arguments: 'This simulates some Javascript code.', + arguments: 'This simulates some JavaScript code.', + ), + ], + ); + }); + + test('runJavascript', () async { + await webViewPlatform.runJavascript( + 'This simulates some JavaScript code.', + ); + + expect( + log, + [ + isMethodCall( + 'runJavascript', + arguments: 'This simulates some JavaScript code.', + ), + ], + ); + }); + + test('runJavascriptReturningResult', () async { + final String evaluateJavascript = + await webViewPlatform.runJavascriptReturningResult( + 'This simulates some JavaScript code.', + ); + + expect('This simulates some JavaScript code.', evaluateJavascript); + expect( + log, + [ + isMethodCall( + 'runJavascriptReturningResult', + arguments: 'This simulates some JavaScript code.', ), ], ); From 174f140651e9ca9c958f2ae75960684c27772a07 Mon Sep 17 00:00:00 2001 From: Camille Simon <43054281+camsim99@users.noreply.github.com> Date: Wed, 6 Oct 2021 17:03:05 -0700 Subject: [PATCH 345/364] [camera] Add filter for unsupported cameras on Android (#4418) --- .../flutter/plugins/camera/CameraUtils.java | 10 +++++ .../plugins/camera/CameraUtilsTest.java | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java index 003d80a6c241..11b6eeaa5b50 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java @@ -97,6 +97,16 @@ public static List> getAvailableCameras(Activity activity) String[] cameraNames = cameraManager.getCameraIdList(); List> cameras = new ArrayList<>(); for (String cameraName : cameraNames) { + int cameraId; + try { + cameraId = Integer.parseInt(cameraName, 10); + } catch (NumberFormatException e) { + cameraId = -1; + } + if (cameraId < 0) { + continue; + } + HashMap details = new HashMap<>(); CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); details.put("name", cameraName); diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java index 6b714ce41e34..e59b05bf4fe3 100644 --- a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraUtilsTest.java @@ -5,8 +5,20 @@ package io.flutter.plugins.camera; import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import android.app.Activity; +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CameraMetadata; import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import java.util.List; +import java.util.Map; import org.junit.Test; public class CameraUtilsTest { @@ -52,4 +64,37 @@ public void deserializeDeviceOrientation_deserializesCorrectly() { public void deserializeDeviceOrientation_throwsForNull() { CameraUtils.deserializeDeviceOrientation(null); } + + @Test + public void getAvailableCameras_retrievesValidCameras() + throws CameraAccessException, NumberFormatException { + final Activity mockActivity = mock(Activity.class); + final CameraManager mockCameraManager = mock(CameraManager.class); + final CameraCharacteristics mockCameraCharacteristics = mock(CameraCharacteristics.class); + final String[] mockCameraIds = {"1394902", "-192930", "0283835", "foobar"}; + final int mockSensorOrientation0 = 90; + final int mockSensorOrientation2 = 270; + final int mockLensFacing0 = CameraMetadata.LENS_FACING_FRONT; + final int mockLensFacing2 = CameraMetadata.LENS_FACING_EXTERNAL; + + when(mockActivity.getSystemService(Context.CAMERA_SERVICE)).thenReturn(mockCameraManager); + when(mockCameraManager.getCameraIdList()).thenReturn(mockCameraIds); + when(mockCameraManager.getCameraCharacteristics(anyString())) + .thenReturn(mockCameraCharacteristics); + when(mockCameraCharacteristics.get(any())) + .thenReturn(mockSensorOrientation0) + .thenReturn(mockLensFacing0) + .thenReturn(mockSensorOrientation2) + .thenReturn(mockLensFacing2); + + List> availableCameras = CameraUtils.getAvailableCameras(mockActivity); + + assertEquals(availableCameras.size(), 2); + assertEquals(availableCameras.get(0).get("name"), "1394902"); + assertEquals(availableCameras.get(0).get("sensorOrientation"), mockSensorOrientation0); + assertEquals(availableCameras.get(0).get("lensFacing"), "front"); + assertEquals(availableCameras.get(1).get("name"), "0283835"); + assertEquals(availableCameras.get(1).get("sensorOrientation"), mockSensorOrientation2); + assertEquals(availableCameras.get(1).get("lensFacing"), "external"); + } } From 5117a3fcd1064ec0b66e4846fcad7f01c9149b73 Mon Sep 17 00:00:00 2001 From: Pavel Shapovalov Date: Fri, 8 Oct 2021 22:38:03 +0300 Subject: [PATCH 346/364] [google_sign_in] Add serverAuthCode attribute to google_sign_in_platform_interface user data (#4179) --- .../google_sign_in_platform_interface/CHANGELOG.md | 4 ++++ .../lib/src/types.dart | 11 ++++++++--- .../lib/src/utils.dart | 3 ++- .../google_sign_in_platform_interface/pubspec.yaml | 2 +- .../test/method_channel_google_sign_in_test.dart | 2 ++ 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md b/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md index ee43db685339..917864173f7d 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 + +* Add serverAuthCode attribute to user data + ## 2.0.1 * Updates `init` function in `MethodChannelGoogleSignIn` to parametrize `clientId` property. diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart index 61231d1b70b9..e30966f87598 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart @@ -31,6 +31,7 @@ class GoogleSignInUserData { this.displayName, this.photoUrl, this.idToken, + this.serverAuthCode, }); /// The display name of the signed in user. @@ -66,9 +67,12 @@ class GoogleSignInUserData { /// data. String? idToken; + /// Server auth code used to access Google Login + String? serverAuthCode; + @override - int get hashCode => - hashObjects([displayName, email, id, photoUrl, idToken]); + int get hashCode => hashObjects( + [displayName, email, id, photoUrl, idToken, serverAuthCode]); @override bool operator ==(dynamic other) { @@ -79,7 +83,8 @@ class GoogleSignInUserData { otherUserData.email == email && otherUserData.id == id && otherUserData.photoUrl == photoUrl && - otherUserData.idToken == idToken; + otherUserData.idToken == idToken && + otherUserData.serverAuthCode == serverAuthCode; } } diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart index 4a70ec4d25ef..0d89835fb498 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart @@ -14,7 +14,8 @@ GoogleSignInUserData? getUserDataFromMap(Map? data) { id: data['id']!, displayName: data['displayName'], photoUrl: data['photoUrl'], - idToken: data['idToken']); + idToken: data['idToken'], + serverAuthCode: data['serverAuthCode']); } /// Converts token data coming from native code into the proper platform interface type. diff --git a/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml b/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml index 75b3d98b562d..aa41039cdf5a 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.0.1 +version: 2.1.0 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart b/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart index 390c12583a79..fcb443f84293 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart @@ -13,6 +13,8 @@ const Map kUserData = { "id": "8162538176523816253123", "photoUrl": "https://lh5.googleusercontent.com/photo.jpg", "displayName": "John Doe", + 'idToken': '123', + 'serverAuthCode': '789', }; const Map kTokenData = { From c254963c1e2b2badb1910d53d6f9bbe8da9d1f5a Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Fri, 8 Oct 2021 19:03:06 -0400 Subject: [PATCH 347/364] [webview_flutter] Adjust test URLs again (#4407) --- .../webview_flutter_test.dart | 51 ++++++++++--------- .../webview_flutter_test.dart | 51 ++++++++++--------- .../webview_flutter_test.dart | 51 ++++++++++--------- 3 files changed, 81 insertions(+), 72 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index 896366798b18..3379bafa2346 100644 --- a/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -18,6 +18,13 @@ import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // URLs to navigate to in tests. These need to be URLs that we are confident will + // always be accessible, and won't do redirection. (E.g., just + // 'https://www.google.com/' will sometimes redirect traffic that looks + // like it's coming from a bot, which is true of these tests). + const String primaryUrl = 'https://flutter.dev/'; + const String secondaryUrl = 'https://www.google.com/robots.txt'; + const bool _skipDueToIssue86757 = true; // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -29,7 +36,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -38,7 +45,7 @@ void main() { ); final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -50,7 +57,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -58,9 +65,9 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.loadUrl('https://www.example.com/'); + await controller.loadUrl(secondaryUrl); final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -74,7 +81,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -292,7 +299,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: _globalKey, - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); @@ -1099,12 +1106,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('location.href = "https://www.example.com/"'); + await controller.evaluateJavascript('location.href = "$secondaryUrl"'); await pageLoads.stream.first; // Wait for the next page load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }); testWidgets('onWebResourceError', (WidgetTester tester) async { @@ -1271,12 +1277,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('location.href = "https://www.example.com"'); + await controller.evaluateJavascript('location.href = "$secondaryUrl"'); await pageLoads.stream.first; // Wait for second page to load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }); }); @@ -1292,7 +1297,7 @@ void main() { height: 300, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, gestureNavigationEnabled: true, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); @@ -1303,7 +1308,7 @@ void main() { ); final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }); testWidgets('target _blank opens in same window', @@ -1327,11 +1332,10 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('window.open("https://flutter.dev/", "_blank")'); + await controller.evaluateJavascript('window.open("$primaryUrl", "_blank")'); await pageLoaded.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }, // Flaky on Android: https://github.com/flutter/flutter/issues/86757 skip: Platform.isAndroid && _skipDueToIssue86757); @@ -1355,25 +1359,24 @@ void main() { onPageFinished: (String url) { pageLoaded.complete(); }, - initialUrl: 'https://flutter.dev', + initialUrl: primaryUrl, ), ), ); final WebViewController controller = await controllerCompleter.future; - expect(controller.currentUrl(), completion('https://flutter.dev/')); + expect(controller.currentUrl(), completion(primaryUrl)); await pageLoaded.future; pageLoaded = Completer(); - await controller - .evaluateJavascript('window.open("https://www.example.com/")'); + await controller.evaluateJavascript('window.open("$secondaryUrl")'); await pageLoaded.future; pageLoaded = Completer(); - expect(controller.currentUrl(), completion('https://www.example.com/')); + expect(controller.currentUrl(), completion(secondaryUrl)); expect(controller.canGoBack(), completion(true)); await controller.goBack(); await pageLoaded.future; - expect(controller.currentUrl(), completion('https://flutter.dev/')); + expect(controller.currentUrl(), completion(primaryUrl)); }, skip: _skipDueToIssue86757, ); diff --git a/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart index 3dab048c8ff2..c57d2bd55580 100644 --- a/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart @@ -22,6 +22,13 @@ import 'package:webview_flutter_platform_interface/webview_flutter_platform_inte void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // URLs to navigate to in tests. These need to be URLs that we are confident will + // always be accessible, and won't do redirection. (E.g., just + // 'https://www.google.com/' will sometimes redirect traffic that looks + // like it's coming from a bot, which is true of these tests). + const String primaryUrl = 'https://flutter.dev/'; + const String secondaryUrl = 'https://www.google.com/robots.txt'; + const bool _skipDueToIssue86757 = true; // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -34,7 +41,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -44,7 +51,7 @@ void main() { ); final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -56,7 +63,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -64,9 +71,9 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.loadUrl('https://www.example.com/'); + await controller.loadUrl(secondaryUrl); final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -80,7 +87,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -298,7 +305,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: _globalKey, - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); @@ -1053,12 +1060,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('location.href = "https://www.example.com/"'); + await controller.evaluateJavascript('location.href = "$secondaryUrl"'); await pageLoads.stream.first; // Wait for the next page load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }); testWidgets('onWebResourceError', (WidgetTester tester) async { @@ -1220,12 +1226,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('location.href = "https://www.example.com"'); + await controller.evaluateJavascript('location.href = "$secondaryUrl"'); await pageLoads.stream.first; // Wait for second page to load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }); }); @@ -1241,7 +1246,7 @@ void main() { height: 300, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, gestureNavigationEnabled: true, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); @@ -1252,7 +1257,7 @@ void main() { ); final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }); testWidgets('target _blank opens in same window', @@ -1276,11 +1281,10 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('window.open("https://flutter.dev/", "_blank")'); + await controller.evaluateJavascript('window.open("$primaryUrl", "_blank")'); await pageLoaded.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }, // Flaky on Android: https://github.com/flutter/flutter/issues/86757 skip: _skipDueToIssue86757); @@ -1304,25 +1308,24 @@ void main() { onPageFinished: (String url) { pageLoaded.complete(); }, - initialUrl: 'https://flutter.dev', + initialUrl: primaryUrl, ), ), ); final WebViewController controller = await controllerCompleter.future; - expect(controller.currentUrl(), completion('https://flutter.dev/')); + expect(controller.currentUrl(), completion(primaryUrl)); await pageLoaded.future; pageLoaded = Completer(); - await controller - .evaluateJavascript('window.open("https://www.example.com/")'); + await controller.evaluateJavascript('window.open("$secondaryUrl")'); await pageLoaded.future; pageLoaded = Completer(); - expect(controller.currentUrl(), completion('https://www.example.com/')); + expect(controller.currentUrl(), completion(secondaryUrl)); expect(controller.canGoBack(), completion(true)); await controller.goBack(); await pageLoaded.future; - expect(controller.currentUrl(), completion('https://flutter.dev/')); + expect(controller.currentUrl(), completion(primaryUrl)); }, skip: _skipDueToIssue86757, ); diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart index c080c46120ec..8ba17a2428c5 100644 --- a/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart @@ -20,6 +20,13 @@ import 'package:webview_flutter_wkwebview_example/web_view.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // URLs to navigate to in tests. These need to be URLs that we are confident will + // always be accessible, and won't do redirection. (E.g., just + // 'https://www.google.com/' will sometimes redirect traffic that looks + // like it's coming from a bot, which is true of these tests). + const String primaryUrl = 'https://flutter.dev/'; + const String secondaryUrl = 'https://www.google.com/robots.txt'; + // Set to `false` to include all flaky tests in the test run. See also https://github.com/flutter/flutter/issues/86757. const bool _skipDueToIssue86757 = false; @@ -32,7 +39,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -41,7 +48,7 @@ void main() { ); final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }, skip: _skipDueToIssue86757); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -53,7 +60,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -61,9 +68,9 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller.loadUrl('https://www.example.com/'); + await controller.loadUrl(secondaryUrl); final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }, skip: _skipDueToIssue86757); testWidgets('loadUrl with headers', (WidgetTester tester) async { @@ -76,7 +83,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); }, @@ -292,7 +299,7 @@ void main() { textDirection: TextDirection.ltr, child: WebView( key: _globalKey, - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); @@ -912,12 +919,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('location.href = "https://www.example.com/"'); + await controller.evaluateJavascript('location.href = "$secondaryUrl"'); await pageLoads.stream.first; // Wait for the next page load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }); testWidgets('onWebResourceError', (WidgetTester tester) async { @@ -1084,12 +1090,11 @@ void main() { await pageLoads.stream.first; // Wait for initial page load. final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('location.href = "https://www.example.com"'); + await controller.evaluateJavascript('location.href = "$secondaryUrl"'); await pageLoads.stream.first; // Wait for second page to load. final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://www.example.com/'); + expect(currentUrl, secondaryUrl); }); }); @@ -1105,7 +1110,7 @@ void main() { height: 300, child: WebView( key: GlobalKey(), - initialUrl: 'https://flutter.dev/', + initialUrl: primaryUrl, gestureNavigationEnabled: true, onWebViewCreated: (WebViewController controller) { controllerCompleter.complete(controller); @@ -1116,7 +1121,7 @@ void main() { ); final WebViewController controller = await controllerCompleter.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }); testWidgets('target _blank opens in same window', @@ -1140,11 +1145,10 @@ void main() { ), ); final WebViewController controller = await controllerCompleter.future; - await controller - .evaluateJavascript('window.open("https://flutter.dev/", "_blank")'); + await controller.evaluateJavascript('window.open("$primaryUrl", "_blank")'); await pageLoaded.future; final String? currentUrl = await controller.currentUrl(); - expect(currentUrl, 'https://flutter.dev/'); + expect(currentUrl, primaryUrl); }); // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. @@ -1166,25 +1170,24 @@ void main() { onPageFinished: (String url) { pageLoaded.complete(); }, - initialUrl: 'https://flutter.dev', + initialUrl: primaryUrl, ), ), ); final WebViewController controller = await controllerCompleter.future; - expect(controller.currentUrl(), completion('https://flutter.dev/')); + expect(controller.currentUrl(), completion(primaryUrl)); await pageLoaded.future; pageLoaded = Completer(); - await controller - .evaluateJavascript('window.open("https://www.example.com/")'); + await controller.evaluateJavascript('window.open("$secondaryUrl")'); await pageLoaded.future; pageLoaded = Completer(); - expect(controller.currentUrl(), completion('https://www.example.com/')); + expect(controller.currentUrl(), completion(secondaryUrl)); expect(controller.canGoBack(), completion(true)); await controller.goBack(); await pageLoaded.future; - expect(controller.currentUrl(), completion('https://flutter.dev/')); + expect(controller.currentUrl(), completion(primaryUrl)); }, skip: _skipDueToIssue86757, ); From 6716d75c08cb04b6bf7ca1ffc5a9592618f87537 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Wed, 13 Oct 2021 10:19:26 -0700 Subject: [PATCH 348/364] Update integration_test README (#3824) * Change deprecated to moved - add a link to the flutter.dev docs - add a link to the new location - Changed "deprecated" to "moved" to avoid confusion, since this is still the recommended way to write unit tests, but needs to be imported differently. * Update packages/integration_test/README.md * Apply suggestions from code review Co-authored-by: Shams Zakhour (ignore Sfshaza) <44418985+sfshaza2@users.noreply.github.com> Co-authored-by: Shams Zakhour (ignore Sfshaza) <44418985+sfshaza2@users.noreply.github.com> --- packages/integration_test/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/integration_test/README.md b/packages/integration_test/README.md index 67f658b56327..6bf388131680 100644 --- a/packages/integration_test/README.md +++ b/packages/integration_test/README.md @@ -1,9 +1,12 @@ -# integration_test (deprecated) +# integration_test (moved) -## DEPRECATED +## MOVED -This package has been moved to the Flutter SDK. Starting with Flutter 2.0, -it should be included as: +This package has [moved to the Flutter +SDK](https://github.com/flutter/flutter/tree/master/packages/integration_test), +and the pub.dev version is deprecated. +As of Flutter 2.0, include it in your pubspec's +dev dependencies section, as follows: ``` dev_dependencies: @@ -11,6 +14,9 @@ dev_dependencies: sdk: flutter ``` +For the latest documentation, see [Integration +testing](https://flutter.dev/docs/testing/integration-tests). + ## Old instructions This package enables self-driving testing of Flutter code on devices and emulators. From 176cfb8083bc85eb071599ee8f5ef65b732b8ffd Mon Sep 17 00:00:00 2001 From: Maurits van Beusekom Date: Thu, 14 Oct 2021 02:21:56 +0200 Subject: [PATCH 349/364] [camera] Update [CameraOrientationTests testOrientationNotifications] unit test to work on Xcode 13 (#4426) --- packages/camera/camera/CHANGELOG.md | 5 ++- .../ios/RunnerTests/CameraOrientationTests.m | 40 +++++++++++-------- .../camera/camera/ios/Classes/CameraPlugin.m | 2 + .../camera/ios/Classes/CameraPlugin.modulemap | 10 +++++ .../camera/ios/Classes/CameraPlugin_Test.h | 19 +++++++++ .../camera/ios/Classes/camera-umbrella.h | 9 +++++ packages/camera/camera/ios/camera.podspec | 3 +- packages/camera/camera/pubspec.yaml | 2 +- 8 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 packages/camera/camera/ios/Classes/CameraPlugin.modulemap create mode 100644 packages/camera/camera/ios/Classes/CameraPlugin_Test.h create mode 100644 packages/camera/camera/ios/Classes/camera-umbrella.h diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index c9dfc63eb46c..dc83a4fabca2 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.9.4+2 -* Updated package description. +* Updated package description; +* Refactor unit test on iOS to make it compatible with new restrictions in Xcode 13 which only supports the use of the `XCUIDevice` in Xcode UI tests. ## 0.9.4+1 diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m index 6c29ef7b2866..246eab90a919 100644 --- a/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m +++ b/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m @@ -3,30 +3,29 @@ // found in the LICENSE file. @import camera; +@import camera.Test; @import XCTest; +@import Flutter; #import @interface CameraOrientationTests : XCTestCase -@property(strong, nonatomic) id mockRegistrar; @property(strong, nonatomic) id mockMessenger; +@property(strong, nonatomic) CameraPlugin *cameraPlugin; @end @implementation CameraOrientationTests - (void)setUp { [super setUp]; - self.mockRegistrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); + self.mockMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); - OCMStub([self.mockRegistrar messenger]).andReturn(self.mockMessenger); + self.cameraPlugin = [[CameraPlugin alloc] initWithRegistry:nil messenger:self.mockMessenger]; } - (void)testOrientationNotifications { id mockMessenger = self.mockMessenger; [mockMessenger setExpectationOrderMatters:YES]; - XCUIDevice.sharedDevice.orientation = UIDeviceOrientationPortrait; - - [CameraPlugin registerWithRegistrar:self.mockRegistrar]; [self rotate:UIDeviceOrientationPortraitUpsideDown expectedChannelOrientation:@"portraitDown"]; [self rotate:UIDeviceOrientationPortrait expectedChannelOrientation:@"portraitUp"]; @@ -34,34 +33,43 @@ - (void)testOrientationNotifications { [self rotate:UIDeviceOrientationLandscapeLeft expectedChannelOrientation:@"landscapeRight"]; OCMReject([mockMessenger sendOnChannel:[OCMArg any] message:[OCMArg any]]); - // No notification when orientation doesn't change. - XCUIDevice.sharedDevice.orientation = UIDeviceOrientationLandscapeLeft; + // No notification when flat. - XCUIDevice.sharedDevice.orientation = UIDeviceOrientationFaceUp; + [self.cameraPlugin + orientationChanged:[self createMockNotificationForOrientation:UIDeviceOrientationFaceUp]]; // No notification when facedown. - XCUIDevice.sharedDevice.orientation = UIDeviceOrientationFaceDown; + [self.cameraPlugin + orientationChanged:[self createMockNotificationForOrientation:UIDeviceOrientationFaceDown]]; OCMVerifyAll(mockMessenger); } - (void)rotate:(UIDeviceOrientation)deviceOrientation - expectedChannelOrientation:(NSString*)channelOrientation { + expectedChannelOrientation:(NSString *)channelOrientation { id mockMessenger = self.mockMessenger; - XCTestExpectation* orientationExpectation = [self expectationWithDescription:channelOrientation]; + XCTestExpectation *orientationExpectation = [self expectationWithDescription:channelOrientation]; OCMExpect([mockMessenger sendOnChannel:[OCMArg any] - message:[OCMArg checkWithBlock:^BOOL(NSData* data) { - NSObject* codec = [FlutterStandardMethodCodec sharedInstance]; - FlutterMethodCall* methodCall = [codec decodeMethodCall:data]; + message:[OCMArg checkWithBlock:^BOOL(NSData *data) { + NSObject *codec = [FlutterStandardMethodCodec sharedInstance]; + FlutterMethodCall *methodCall = [codec decodeMethodCall:data]; [orientationExpectation fulfill]; return [methodCall.method isEqualToString:@"orientation_changed"] && [methodCall.arguments isEqualToDictionary:@{@"orientation" : channelOrientation}]; }]]); - XCUIDevice.sharedDevice.orientation = deviceOrientation; + [self.cameraPlugin + orientationChanged:[self createMockNotificationForOrientation:deviceOrientation]]; [self waitForExpectationsWithTimeout:30.0 handler:nil]; } +- (NSNotification *)createMockNotificationForOrientation:(UIDeviceOrientation)deviceOrientation { + UIDevice *mockDevice = OCMClassMock([UIDevice class]); + OCMStub([mockDevice orientation]).andReturn(deviceOrientation); + + return [NSNotification notificationWithName:@"orientation_test" object:mockDevice]; +} + @end diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index da560d6c4df7..78c5a34ab05a 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -3,6 +3,8 @@ // found in the LICENSE file. #import "CameraPlugin.h" +#import "CameraPlugin_Test.h" + #import #import #import diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.modulemap b/packages/camera/camera/ios/Classes/CameraPlugin.modulemap new file mode 100644 index 000000000000..30afa91bdda2 --- /dev/null +++ b/packages/camera/camera/ios/Classes/CameraPlugin.modulemap @@ -0,0 +1,10 @@ +framework module camera { + umbrella header "camera-umbrella.h" + + export * + module * { export * } + + explicit module Test { + header "CameraPlugin_Test.h" + } +} diff --git a/packages/camera/camera/ios/Classes/CameraPlugin_Test.h b/packages/camera/camera/ios/Classes/CameraPlugin_Test.h new file mode 100644 index 000000000000..6952ae0a4aaa --- /dev/null +++ b/packages/camera/camera/ios/Classes/CameraPlugin_Test.h @@ -0,0 +1,19 @@ +// Copyright 2013 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. + +// This header is available in the Test module. Import via "@import camera.Test;" + +#import + +/// Methods exposed for unit testing. +@interface CameraPlugin () + +- (instancetype)initWithRegistry:(NSObject *)registry + messenger:(NSObject *)messenger + NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +- (void)orientationChanged:(NSNotification *)notification; + +@end diff --git a/packages/camera/camera/ios/Classes/camera-umbrella.h b/packages/camera/camera/ios/Classes/camera-umbrella.h new file mode 100644 index 000000000000..5c39401e6261 --- /dev/null +++ b/packages/camera/camera/ios/Classes/camera-umbrella.h @@ -0,0 +1,9 @@ +// Copyright 2013 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 +#import + +FOUNDATION_EXPORT double cameraVersionNumber; +FOUNDATION_EXPORT const unsigned char cameraVersionString[]; diff --git a/packages/camera/camera/ios/camera.podspec b/packages/camera/camera/ios/camera.podspec index 4a142bd4589a..5906bf98af45 100644 --- a/packages/camera/camera/ios/camera.podspec +++ b/packages/camera/camera/ios/camera.podspec @@ -13,8 +13,9 @@ A Flutter plugin to use the camera from your Flutter app. s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/camera' } s.documentation_url = 'https://pub.dev/packages/camera' - s.source_files = 'Classes/**/*' + s.source_files = 'Classes/**/*.{h,m}' s.public_header_files = 'Classes/**/*.h' + s.module_map = 'Classes/CameraPlugin.modulemap' s.dependency 'Flutter' s.platform = :ios, '9.0' diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 21892b213781..f68d026b206c 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.4+1 +version: 0.9.4+2 environment: sdk: ">=2.14.0 <3.0.0" From 9e46048ad2e1f085c1e8f6c77391fa52025e681f Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Fri, 15 Oct 2021 18:18:02 -0700 Subject: [PATCH 350/364] Bump compileSdkVersion to 31 (#4432) --- packages/camera/camera/android/build.gradle | 2 +- script/tool/lib/src/create_all_plugins_app_command.dart | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/camera/camera/android/build.gradle b/packages/camera/camera/android/build.gradle index 633efd0b284a..25285ad33205 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -1,6 +1,6 @@ group 'io.flutter.plugins.camera' version '1.0-SNAPSHOT' -def args = ["-Xlint:deprecation","-Xlint:unchecked","-Werror"] +def args = ["-Xlint:deprecation","-Xlint:unchecked"] buildscript { repositories { diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index 6dbebf2f5c74..5d9b4ed9c728 100644 --- a/script/tool/lib/src/create_all_plugins_app_command.dart +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -93,10 +93,13 @@ class CreateAllPluginsAppCommand extends PluginCommand { final StringBuffer newGradle = StringBuffer(); for (final String line in gradleFile.readAsLinesSync()) { - if (line.contains('minSdkVersion 16')) { - // Android SDK 20 is required by Google maps. - // Android SDK 19 is required by WebView. + if (line.contains('minSdkVersion')) { + // minSdkVersion 20 is required by Google maps. + // minSdkVersion 19 is required by WebView. newGradle.writeln('minSdkVersion 20'); + } else if (line.contains('compileSdkVersion')) { + // compileSdkVersion 31 is required by Camera. + newGradle.writeln('compileSdkVersion 31'); } else { newGradle.writeln(line); } From aae841aa5a7062f05e0aba4f9304dd605dbbc2b2 Mon Sep 17 00:00:00 2001 From: Balvinder Singh Gambhir Date: Mon, 18 Oct 2021 23:38:03 +0530 Subject: [PATCH 351/364] [image_picker_for_web] Added support for maxWidth, maxHeight and imageQuality (#4389) --- .../image_picker_for_web/CHANGELOG.md | 5 + .../image_picker_for_web/README.md | 5 +- .../integration_test/image_resizer_test.dart | 128 ++++++++++++++++++ .../lib/image_picker_for_web.dart | 51 +++++-- .../lib/src/image_resizer.dart | 83 ++++++++++++ .../lib/src/image_resizer_utils.dart | 33 +++++ .../image_picker_for_web/pubspec.yaml | 2 +- .../test/image_resizer_utils_test.dart | 92 +++++++++++++ 8 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 packages/image_picker/image_picker_for_web/example/integration_test/image_resizer_test.dart create mode 100644 packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart create mode 100644 packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart create mode 100644 packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart diff --git a/packages/image_picker/image_picker_for_web/CHANGELOG.md b/packages/image_picker/image_picker_for_web/CHANGELOG.md index d11ead3bb64e..aee8b0863c13 100644 --- a/packages/image_picker/image_picker_for_web/CHANGELOG.md +++ b/packages/image_picker/image_picker_for_web/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.1.4 + +* Implemented `maxWidth`, `maxHeight` and `imageQuality` when selecting images + (except for gifs). + ## 2.1.3 * Add `implements` to pubspec. diff --git a/packages/image_picker/image_picker_for_web/README.md b/packages/image_picker/image_picker_for_web/README.md index 73f2dfc4b84f..c8b85f21cc89 100644 --- a/packages/image_picker/image_picker_for_web/README.md +++ b/packages/image_picker/image_picker_for_web/README.md @@ -43,7 +43,8 @@ Each browser may implement `capture` any way they please, so it may (or may not) difference in your users' experience. ### pickImage() -The arguments `maxWidth`, `maxHeight` and `imageQuality` are not supported on the web. +The arguments `maxWidth`, `maxHeight` and `imageQuality` are not supported for gif images. +The argument `imageQuality` only works for jpeg and webp images. ### pickVideo() The argument `maxDuration` is not supported on the web. @@ -63,7 +64,7 @@ You should be able to use `package:image_picker` _almost_ as normal. Once the user has picked a file, the returned `PickedFile` instance will contain a `network`-accessible URL (pointing to a location within the browser). -The instace will also let you retrieve the bytes of the selected file across all platforms. +The instance will also let you retrieve the bytes of the selected file across all platforms. If you want to use the path directly, your code would need look like this: diff --git a/packages/image_picker/image_picker_for_web/example/integration_test/image_resizer_test.dart b/packages/image_picker/image_picker_for_web/example/integration_test/image_resizer_test.dart new file mode 100644 index 000000000000..067c7750eb11 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/integration_test/image_resizer_test.dart @@ -0,0 +1,128 @@ +// Copyright 2013 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:async'; +import 'dart:html' as html; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker_for_web/src/image_resizer.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +//This is a sample 10x10 png image +final String pngFileBase64Contents = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKAQMAAAC3/F3+AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEXqQzX+/v6lfubTAAAAAWJLR0QB/wIt3gAAAAlwSFlzAAAHEwAABxMBziAPCAAAAAd0SU1FB+UJHgsdDM0ErZoAAAALSURBVAjXY2DABwAAHgABboVHMgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMS0wOS0zMFQxMToyOToxMi0wNDowMHCDC24AAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjEtMDktMzBUMTE6Mjk6MTItMDQ6MDAB3rPSAAAAAElFTkSuQmCC"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Under test... + late ImageResizer imageResizer; + late XFile pngFile; + setUp(() { + imageResizer = ImageResizer(); + final pngHtmlFile = _base64ToFile(pngFileBase64Contents, "pngImage.png"); + pngFile = XFile(html.Url.createObjectUrl(pngHtmlFile), + name: pngHtmlFile.name, mimeType: pngHtmlFile.type); + }); + + testWidgets("image is loaded correctly ", (WidgetTester tester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + expect(imageElement.width!, 10); + expect(imageElement.height!, 10); + }); + + testWidgets( + "canvas is loaded with image's width and height when max width and max height are null", + (widgetTester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + final canvas = imageResizer.resizeImageElement(imageElement, null, null); + expect(canvas.width, imageElement.width); + expect(canvas.height, imageElement.height); + }); + + testWidgets( + "canvas size is scaled when max width and max height are not null", + (widgetTester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + final canvas = imageResizer.resizeImageElement(imageElement, 8, 8); + expect(canvas.width, 8); + expect(canvas.height, 8); + }); + + testWidgets("resized image is returned after converting canvas to file", + (widgetTester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + final canvas = imageResizer.resizeImageElement(imageElement, null, null); + final resizedImage = + await imageResizer.writeCanvasToFile(pngFile, canvas, null); + expect(resizedImage.name, "scaled_${pngFile.name}"); + }); + + testWidgets("image is scaled when maxWidth is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, 5, null, null); + expect(scaledImage.name, "scaled_${pngFile.name}"); + final scaledImageSize = await _getImageSize(scaledImage); + expect(scaledImageSize, Size(5, 5)); + }); + + testWidgets("image is scaled when maxHeight is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, null, 6, null); + expect(scaledImage.name, "scaled_${pngFile.name}"); + final scaledImageSize = await _getImageSize(scaledImage); + expect(scaledImageSize, Size(6, 6)); + }); + + testWidgets("image is scaled when imageQuality is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, null, null, 89); + expect(scaledImage.name, "scaled_${pngFile.name}"); + }); + + testWidgets("image is scaled when maxWidth,maxHeight,imageQuality are set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, 3, 4, 89); + expect(scaledImage.name, "scaled_${pngFile.name}"); + }); + + testWidgets("image is not scaled when maxWidth,maxHeight, is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, null, null, null); + expect(scaledImage.name, pngFile.name); + }); +} + +Future _getImageSize(XFile file) async { + final completer = Completer(); + final image = html.ImageElement(src: file.path); + image.onLoad.listen((event) { + completer.complete(Size(image.width!.toDouble(), image.height!.toDouble())); + }); + image.onError.listen((event) { + completer.complete(Size(0, 0)); + }); + return completer.future; +} + +html.File _base64ToFile(String data, String fileName) { + var arr = data.split(','); + var bstr = html.window.atob(arr[1]); + var n = bstr.length, u8arr = Uint8List(n); + + while (n >= 1) { + u8arr[n - 1] = bstr.codeUnitAt(n - 1); + n--; + } + + return html.File([u8arr], fileName); +} diff --git a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart index b170ee3256ab..f13aeb1f0a4e 100644 --- a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart +++ b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:html' as html; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:image_picker_for_web/src/image_resizer.dart'; import 'package:meta/meta.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; @@ -23,10 +24,14 @@ class ImagePickerPlugin extends ImagePickerPlatform { late html.Element _target; + late ImageResizer _imageResizer; + /// A constructor that allows tests to override the function that creates file inputs. ImagePickerPlugin({ @visibleForTesting ImagePickerPluginTestOverrides? overrides, + @visibleForTesting ImageResizer? imageResizer, }) : _overrides = overrides { + _imageResizer = imageResizer ?? ImageResizer(); _target = _ensureInitialized(_kImagePickerInputsDomId); } @@ -122,7 +127,12 @@ class ImagePickerPlugin extends ImagePickerPlatform { accept: _kAcceptImageMimeType, capture: capture, ); - return files.first; + return _imageResizer.resizeImageIfNeeded( + files.first, + maxWidth, + maxHeight, + imageQuality, + ); } /// Returns an [XFile] containing the video that was picked. @@ -157,8 +167,21 @@ class ImagePickerPlugin extends ImagePickerPlatform { double? maxWidth, double? maxHeight, int? imageQuality, - }) { - return getFiles(accept: _kAcceptImageMimeType, multiple: true); + }) async { + final List images = await getFiles( + accept: _kAcceptImageMimeType, + multiple: true, + ); + final Iterable> resized = images.map( + (image) => _imageResizer.resizeImageIfNeeded( + image, + maxWidth, + maxHeight, + imageQuality, + ), + ); + + return Future.wait(resized); } /// Injects a file input with the specified accept+capture attributes, and @@ -244,17 +267,17 @@ class ImagePickerPlugin extends ImagePickerPlatform { input.onChange.first.then((event) { final files = _handleOnChangeEvent(event); if (!_completer.isCompleted && files != null) { - _completer.complete(files - .map((file) => XFile( - html.Url.createObjectUrl(file), - name: file.name, - length: file.size, - lastModified: DateTime.fromMillisecondsSinceEpoch( - file.lastModified ?? DateTime.now().millisecondsSinceEpoch, - ), - mimeType: file.type, - )) - .toList()); + _completer.complete(files.map((file) { + return XFile( + html.Url.createObjectUrl(file), + name: file.name, + length: file.size, + lastModified: DateTime.fromMillisecondsSinceEpoch( + file.lastModified ?? DateTime.now().millisecondsSinceEpoch, + ), + mimeType: file.type, + ); + }).toList()); } }); input.onError.first.then((event) { diff --git a/packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart b/packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart new file mode 100644 index 000000000000..6ee7c5f015e2 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart @@ -0,0 +1,83 @@ +// Copyright 2013 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:async'; +import 'dart:math'; +import 'dart:ui'; +import 'package:image_picker_for_web/src/image_resizer_utils.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'dart:html' as html; + +/// Helper class that resizes images. +class ImageResizer { + /// Resizes the image if needed. + /// (Does not support gif images) + Future resizeImageIfNeeded(XFile file, double? maxWidth, + double? maxHeight, int? imageQuality) async { + if (!imageResizeNeeded(maxWidth, maxHeight, imageQuality) || + file.mimeType == "image/gif") { + // Implement maxWidth and maxHeight for image/gif + return file; + } + try { + final imageElement = await loadImage(file.path); + final canvas = resizeImageElement(imageElement, maxWidth, maxHeight); + final resizedImage = await writeCanvasToFile(file, canvas, imageQuality); + html.Url.revokeObjectUrl(file.path); + return resizedImage; + } catch (e) { + return file; + } + } + + /// function that loads the blobUrl into an imageElement + Future loadImage(String blobUrl) { + final imageLoadCompleter = Completer(); + final imageElement = html.ImageElement(); + imageElement.src = blobUrl; + + imageElement.onLoad.listen((event) { + imageLoadCompleter.complete(imageElement); + }); + imageElement.onError.listen((event) { + final exception = ("Error while loading image."); + imageElement.remove(); + imageLoadCompleter.completeError(exception); + }); + return imageLoadCompleter.future; + } + + /// Draws image to a canvas while resizing the image to fit the [maxWidth],[maxHeight] constraints + html.CanvasElement resizeImageElement( + html.ImageElement source, double? maxWidth, double? maxHeight) { + final newImageSize = calculateSizeOfDownScaledImage( + Size(source.width!.toDouble(), source.height!.toDouble()), + maxWidth, + maxHeight); + final canvas = html.CanvasElement(); + canvas.width = newImageSize.width.toInt(); + canvas.height = newImageSize.height.toInt(); + final context = canvas.context2D; + if (maxHeight == null && maxWidth == null) { + context.drawImage(source, 0, 0); + } else { + context.drawImageScaled(source, 0, 0, canvas.width!, canvas.height!); + } + return canvas; + } + + /// function that converts a canvas element to Xfile + /// [imageQuality] is only supported for jpeg and webp images. + Future writeCanvasToFile( + XFile originalFile, html.CanvasElement canvas, int? imageQuality) async { + final calculatedImageQuality = ((min(imageQuality ?? 100, 100)) / 100.0); + final blob = + await canvas.toBlob(originalFile.mimeType, calculatedImageQuality); + return XFile(html.Url.createObjectUrlFromBlob(blob), + mimeType: originalFile.mimeType, + name: "scaled_" + originalFile.name, + lastModified: DateTime.now(), + length: blob.size); + } +} diff --git a/packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart b/packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart new file mode 100644 index 000000000000..6ef789254b3f --- /dev/null +++ b/packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart @@ -0,0 +1,33 @@ +// Copyright 2013 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:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +///a function that checks if an image needs to be resized or not +bool imageResizeNeeded(double? maxWidth, double? maxHeight, int? imageQuality) { + return imageQuality != null + ? isImageQualityValid(imageQuality) + : (maxWidth != null || maxHeight != null); +} + +/// a function that checks if image quality is between 0 to 100 +bool isImageQualityValid(int imageQuality) { + return (imageQuality >= 0 && imageQuality <= 100); +} + +/// a function that calculates the size of the downScaled image. +/// imageWidth is the width of the image +/// imageHeight is the height of the image +/// maxWidth is the maximum width of the scaled image +/// maxHeight is the maximum height of the scaled image +Size calculateSizeOfDownScaledImage( + Size imageSize, double? maxWidth, double? maxHeight) { + double widthFactor = maxWidth != null ? imageSize.width / maxWidth : 1; + double heightFactor = maxHeight != null ? imageSize.height / maxHeight : 1; + double resizeFactor = max(widthFactor, heightFactor); + return (resizeFactor > 1 ? imageSize ~/ resizeFactor : imageSize); +} diff --git a/packages/image_picker/image_picker_for_web/pubspec.yaml b/packages/image_picker/image_picker_for_web/pubspec.yaml index 895486f3de06..d0d97011c9ec 100644 --- a/packages/image_picker/image_picker_for_web/pubspec.yaml +++ b/packages/image_picker/image_picker_for_web/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_for_web description: Web platform implementation of image_picker repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker_for_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 2.1.3 +version: 2.1.4 environment: sdk: ">=2.12.0 <3.0.0" diff --git a/packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart b/packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart new file mode 100644 index 000000000000..352d2bea48a5 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart @@ -0,0 +1,92 @@ +// Copyright 2013 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:ui'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:image_picker_for_web/src/image_resizer_utils.dart'; + +void main() { + group('Image Resizer Utils', () { + group("calculateSizeOfScaledImage", () { + test( + "scaled image height and width are same if max width and max height are same as image's width and height", + () { + expect(calculateSizeOfDownScaledImage(Size(500, 300), 500, 300), + Size(500, 300)); + }); + + test( + "scaled image height and width are same if max width and max height are null", + () { + expect(calculateSizeOfDownScaledImage(Size(500, 300), null, null), + Size(500, 300)); + }); + + test("image size is scaled when maxWidth is set", () { + final imageSize = Size(500, 300); + final maxWidth = 400; + final scaledSize = calculateSizeOfDownScaledImage( + Size(imageSize.width, imageSize.height), maxWidth.toDouble(), null); + expect(scaledSize.height <= imageSize.height, true); + expect(scaledSize.width <= maxWidth, true); + }); + + test("image size is scaled when maxHeight is set", () { + final imageSize = Size(500, 300); + final maxHeight = 400; + final scaledSize = calculateSizeOfDownScaledImage( + Size(imageSize.width, imageSize.height), + null, + maxHeight.toDouble()); + expect(scaledSize.height <= maxHeight, true); + expect(scaledSize.width <= imageSize.width, true); + }); + + test("image size is scaled when both maxWidth and maxHeight is set", () { + final imageSize = Size(1120, 2000); + final maxHeight = 1200; + final maxWidth = 99; + final scaledSize = calculateSizeOfDownScaledImage( + Size(imageSize.width, imageSize.height), + maxWidth.toDouble(), + maxHeight.toDouble()); + expect(scaledSize.height <= maxHeight, true); + expect(scaledSize.width <= maxWidth, true); + }); + }); + group("imageResizeNeeded", () { + test("image needs to be resized when maxWidth is set", () { + expect(imageResizeNeeded(50, null, null), true); + }); + + test("image needs to be resized when maxHeight is set", () { + expect(imageResizeNeeded(null, 50, null), true); + }); + + test("image needs to be resized when imageQuality is set", () { + expect(imageResizeNeeded(null, null, 100), true); + }); + + test("image will not be resized when imageQuality is not valid", () { + expect(imageResizeNeeded(null, null, 101), false); + expect(imageResizeNeeded(null, null, -1), false); + }); + }); + + group("isImageQualityValid", () { + test("image quality is valid in 0 to 100", () { + expect(isImageQualityValid(50), true); + expect(isImageQualityValid(0), true); + expect(isImageQualityValid(100), true); + }); + + test( + "image quality is not valid when imageQuality is less than 0 or greater than 100", + () { + expect(isImageQualityValid(-1), false); + expect(isImageQualityValid(101), false); + }); + }); + }); +} From fbb7d3aa514be062407c5b9399c3acf93d79dec9 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Tue, 19 Oct 2021 10:48:42 -0700 Subject: [PATCH 352/364] [ci] Replace Firebase Test Lab deprecated Pixel 4 device with Pixel 5 (#4436) --- .cirrus.yml | 2 +- script/tool/CHANGELOG.md | 1 + .../lib/src/firebase_test_lab_command.dart | 2 +- .../test/firebase_test_lab_command_test.dart | 34 +++++++++---------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index ef6b9c1b6d44..cb6d0dfd63fc 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -214,7 +214,7 @@ task: - export CIRRUS_COMMIT_MESSAGE="" - if [[ -n "$GCLOUD_FIREBASE_TESTLAB_KEY" ]]; then - echo $GCLOUD_FIREBASE_TESTLAB_KEY > ${HOME}/gcloud-service-key.json - - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 --exclude=script/configs/exclude_integration_android.yaml + - ./script/tool_runner.sh firebase-test-lab --device model=redfin,version=30 --device model=starqlteue,version=26 --exclude=script/configs/exclude_integration_android.yaml - else - echo "This user does not have permission to run Firebase Test Lab tests." - fi diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 2e6404e2cee4..3b4ff8f226d8 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,5 +1,6 @@ ## NEXT +- Update Firebase Testlab deprecated test device. (Pixel 4 API 29 -> Pixel 5 API 30). - `native-test --android`, `--ios`, and `--macos` now fail plugins that don't have unit tests, rather than skipping them. - Added a new `federation-safety-check` command to help catch changes to diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart index 941cba3a6945..28afc638203f 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -54,7 +54,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { splitCommas: false, defaultsTo: [ 'model=walleye,version=26', - 'model=flame,version=29' + 'model=redfin,version=30' ], help: 'Device model(s) to test. See https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run for more info'); diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart index e39ccf30b136..268210d00425 100644 --- a/script/tool/test/firebase_test_lab_command_test.dart +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -101,7 +101,7 @@ void main() { final List output = await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -142,7 +142,7 @@ void main() { '/packages/plugin1/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin1/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin1/buildId/testRunId/0/ --device model=redfin,version=30 --device model=seoul,version=26' .split(' '), '/packages/plugin1/example'), ProcessCall( @@ -156,7 +156,7 @@ void main() { '/packages/plugin2/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin2/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin2/buildId/testRunId/0/ --device model=redfin,version=30 --device model=seoul,version=26' .split(' '), '/packages/plugin2/example'), ]), @@ -176,7 +176,7 @@ void main() { final List output = await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -219,7 +219,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=redfin,version=30 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ProcessCall( @@ -229,7 +229,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=redfin,version=30 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ]), @@ -257,7 +257,7 @@ void main() { [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -294,7 +294,7 @@ void main() { [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -332,7 +332,7 @@ void main() { [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -366,7 +366,7 @@ void main() { final List output = await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -400,7 +400,7 @@ void main() { final List output = await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--device', 'model=seoul,version=26', '--test-run-id', @@ -445,7 +445,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=redfin,version=30 --device model=seoul,version=26' .split(' '), '/packages/plugin/example'), ]), @@ -468,7 +468,7 @@ void main() { [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', ], errorHandler: (Error e) { commandError = e; @@ -505,7 +505,7 @@ void main() { [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', ], errorHandler: (Error e) { commandError = e; @@ -543,7 +543,7 @@ void main() { [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', ], errorHandler: (Error e) { commandError = e; @@ -571,7 +571,7 @@ void main() { await runCapturingPrint(runner, [ 'firebase-test-lab', '--device', - 'model=flame,version=29', + 'model=redfin,version=30', '--test-run-id', 'testRunId', '--build-id', @@ -601,7 +601,7 @@ void main() { '/packages/plugin/example/android'), ProcessCall( 'gcloud', - 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=flame,version=29' + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=redfin,version=30' .split(' '), '/packages/plugin/example'), ]), From 3bffbf87fe9688f73ba965e4509ba3c404eb1894 Mon Sep 17 00:00:00 2001 From: Lukas Kurz Date: Tue, 19 Oct 2021 22:33:05 +0200 Subject: [PATCH 353/364] [image_picker][android] suppress unchecked warning (#4408) --- packages/image_picker/image_picker/CHANGELOG.md | 4 ++++ .../io/flutter/plugins/imagepicker/ImagePickerDelegate.java | 1 + packages/image_picker/image_picker/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 4c89be1c3e48..90829cc7ae6a 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.4+3 + +* Suppress a unchecked cast build warning. + ## 0.8.4+2 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java index a60c1f173041..dddf67e6a382 100644 --- a/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java +++ b/packages/image_picker/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/ImagePickerDelegate.java @@ -217,6 +217,7 @@ void saveStateBeforeResult() { void retrieveLostImage(MethodChannel.Result result) { Map resultMap = cache.getCacheMap(); + @SuppressWarnings("unchecked") ArrayList pathList = (ArrayList) resultMap.get(cache.MAP_KEY_PATH_LIST); ArrayList newPathList = new ArrayList<>(); if (pathList != null) { diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index ba5ce6635ed6..99766702cfdf 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/plugins/tree/master/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.8.4+2 +version: 0.8.4+3 environment: sdk: ">=2.14.0 <3.0.0" From fecd22e1de559efd62feb99141d7c4984b77afa0 Mon Sep 17 00:00:00 2001 From: James McIntosh Date: Wed, 20 Oct 2021 13:47:22 +1300 Subject: [PATCH 354/364] [google_maps_flutter] Clean Java test, consolidate Marker example. (#4400) * Do not mock internal class * Merge "Marker" examples into a single page. --- .../googlemaps/MarkersControllerTest.java | 10 +- .../example/lib/drag_marker.dart | 156 ----------- .../google_maps_flutter/example/lib/main.dart | 2 - .../example/lib/place_marker.dart | 246 +++++++++--------- 4 files changed, 131 insertions(+), 283 deletions(-) delete mode 100644 packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java index afe91d77e2be..3ca78e7674d7 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java @@ -9,7 +9,6 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; -import com.google.android.gms.internal.maps.zzt; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Marker; @@ -35,8 +34,7 @@ public void controller_OnMarkerDragStart() { final GoogleMap googleMap = mock(GoogleMap.class); controller.setGoogleMap(googleMap); - final zzt z = mock(zzt.class); - final Marker marker = new Marker(z); + final Marker marker = mock(Marker.class); final String googleMarkerId = "abc123"; @@ -69,8 +67,7 @@ public void controller_OnMarkerDragEnd() { final GoogleMap googleMap = mock(GoogleMap.class); controller.setGoogleMap(googleMap); - final zzt z = mock(zzt.class); - final Marker marker = new Marker(z); + final Marker marker = mock(Marker.class); final String googleMarkerId = "abc123"; @@ -103,8 +100,7 @@ public void controller_OnMarkerDrag() { final GoogleMap googleMap = mock(GoogleMap.class); controller.setGoogleMap(googleMap); - final zzt z = mock(zzt.class); - final Marker marker = new Marker(z); + final Marker marker = mock(Marker.class); final String googleMarkerId = "abc123"; diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart deleted file mode 100644 index 2c7929115247..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/drag_marker.dart +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright 2013 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. - -// ignore_for_file: public_member_api_docs - -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; - -import 'page.dart'; - -class DragMarkerPage extends GoogleMapExampleAppPage { - DragMarkerPage() : super(const Icon(Icons.drag_handle), 'Drag marker'); - - @override - Widget build(BuildContext context) { - return const DragMarkerBody(); - } -} - -class DragMarkerBody extends StatefulWidget { - const DragMarkerBody(); - - @override - State createState() => DragMarkerBodyState(); -} - -typedef MarkerUpdateAction = Marker Function(Marker marker); - -class DragMarkerBodyState extends State { - DragMarkerBodyState(); - static const LatLng center = LatLng(-33.86711, 151.1947171); - - GoogleMapController? controller; - Map markers = {}; - MarkerId? selectedMarker; - int _markerIdCounter = 1; - LatLng? markerPosition; - - void _onMapCreated(GoogleMapController controller) { - this.controller = controller; - } - - void _onMarkerTapped(MarkerId markerId) { - final Marker? tappedMarker = markers[markerId]; - if (tappedMarker != null) { - setState(() { - if (markers.containsKey(selectedMarker)) { - final Marker resetOld = markers[selectedMarker]! - .copyWith(iconParam: BitmapDescriptor.defaultMarker); - markers[selectedMarker!] = resetOld; - } - selectedMarker = markerId; - final Marker newMarker = tappedMarker.copyWith( - iconParam: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueGreen, - ), - ); - markers[markerId] = newMarker; - }); - } - } - - void _onMarkerDrag(MarkerId markerId, LatLng newPosition) async { - setState(() { - this.markerPosition = newPosition; - }); - } - - void _add() { - final int markerCount = markers.length; - - if (markerCount == 12) { - return; - } - - final String markerIdVal = 'marker_id_$_markerIdCounter'; - _markerIdCounter++; - final MarkerId markerId = MarkerId(markerIdVal); - - final Marker marker = Marker( - draggable: true, - markerId: markerId, - position: LatLng( - center.latitude + sin(_markerIdCounter * pi / 6.0) / 20.0, - center.longitude + cos(_markerIdCounter * pi / 6.0) / 20.0, - ), - infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), - onTap: () => _onMarkerTapped(markerId), - onDrag: (LatLng position) => _onMarkerDrag(markerId, position), - ); - - setState(() { - markers[markerId] = marker; - }); - } - - void _remove() { - setState(() { - if (markers.containsKey(selectedMarker)) { - markers.remove(selectedMarker); - } - }); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Center( - child: GoogleMap( - onMapCreated: _onMapCreated, - initialCameraPosition: const CameraPosition( - target: center, - zoom: 11.0, - ), - markers: markers.values.toSet(), - ), - ), - ), - Container( - height: 30, - padding: EdgeInsets.only(left: 12, right: 12), - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - markerPosition == null - ? Container() - : Expanded(child: Text("lat: ${markerPosition!.latitude}")), - markerPosition == null - ? Container() - : Expanded(child: Text("lng: ${markerPosition!.longitude}")), - ], - ), - ), - Row( - children: [ - TextButton( - child: const Text('add'), - onPressed: _add, - ), - TextButton( - child: const Text('remove'), - onPressed: _remove, - ), - ], - ), - ], - ); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart index 16f242c9e0ce..15b14db0357a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart @@ -5,7 +5,6 @@ // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; -import 'package:google_maps_flutter_example/drag_marker.dart'; import 'package:google_maps_flutter_example/lite_mode.dart'; import 'animate_camera.dart'; import 'map_click.dart'; @@ -35,7 +34,6 @@ final List _allPages = [ PlacePolylinePage(), PlacePolygonPage(), PlaceCirclePage(), - DragMarkerPage(), PaddingPage(), SnapshotPage(), LiteModePage(), diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart index 53f553eb67f8..4b9496f69a63 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart @@ -40,6 +40,7 @@ class PlaceMarkerBodyState extends State { Map markers = {}; MarkerId? selectedMarker; int _markerIdCounter = 1; + LatLng? markerPosition; void _onMapCreated(GoogleMapController controller) { this.controller = controller; @@ -67,13 +68,24 @@ class PlaceMarkerBodyState extends State { ), ); markers[markerId] = newMarker; + + markerPosition = null; }); } } + void _onMarkerDrag(MarkerId markerId, LatLng newPosition) async { + setState(() { + this.markerPosition = newPosition; + }); + } + void _onMarkerDragEnd(MarkerId markerId, LatLng newPosition) async { final Marker? tappedMarker = markers[markerId]; if (tappedMarker != null) { + setState(() { + this.markerPosition = null; + }); await showDialog( context: context, builder: (BuildContext context) { @@ -115,12 +127,9 @@ class PlaceMarkerBodyState extends State { center.longitude + cos(_markerIdCounter * pi / 6.0) / 20.0, ), infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), - onTap: () { - _onMarkerTapped(markerId); - }, - onDragEnd: (LatLng position) { - _onMarkerDragEnd(markerId, position); - }, + onTap: () => _onMarkerTapped(markerId), + onDragEnd: (LatLng position) => _onMarkerDragEnd(markerId, position), + onDrag: (LatLng position) => _onMarkerDrag(markerId, position), ); setState(() { @@ -280,14 +289,12 @@ class PlaceMarkerBodyState extends State { @override Widget build(BuildContext context) { final MarkerId? selectedId = selectedMarker; - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: SizedBox( - width: 300.0, - height: 200.0, + return Stack(children: [ + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( child: GoogleMap( onMapCreated: _onMapCreated, initialCameraPosition: const CameraPosition( @@ -297,111 +304,114 @@ class PlaceMarkerBodyState extends State { markers: Set.of(markers.values), ), ), - ), - Expanded( - child: SingleChildScrollView( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Row( - children: [ - Column( - children: [ - TextButton( - child: const Text('add'), - onPressed: _add, - ), - TextButton( - child: const Text('remove'), - onPressed: selectedId == null - ? null - : () => _remove(selectedId), - ), - TextButton( - child: const Text('change info'), - onPressed: selectedId == null - ? null - : () => _changeInfo(selectedId), - ), - TextButton( - child: const Text('change info anchor'), - onPressed: selectedId == null - ? null - : () => _changeInfoAnchor(selectedId), - ), - ], - ), - Column( - children: [ - TextButton( - child: const Text('change alpha'), - onPressed: selectedId == null - ? null - : () => _changeAlpha(selectedId), - ), - TextButton( - child: const Text('change anchor'), - onPressed: selectedId == null - ? null - : () => _changeAnchor(selectedId), - ), - TextButton( - child: const Text('toggle draggable'), - onPressed: selectedId == null - ? null - : () => _toggleDraggable(selectedId), - ), - TextButton( - child: const Text('toggle flat'), - onPressed: selectedId == null - ? null - : () => _toggleFlat(selectedId), - ), - TextButton( - child: const Text('change position'), - onPressed: selectedId == null - ? null - : () => _changePosition(selectedId), - ), - TextButton( - child: const Text('change rotation'), - onPressed: selectedId == null - ? null - : () => _changeRotation(selectedId), - ), - TextButton( - child: const Text('toggle visible'), - onPressed: selectedId == null - ? null - : () => _toggleVisible(selectedId), - ), - TextButton( - child: const Text('change zIndex'), - onPressed: selectedId == null - ? null - : () => _changeZIndex(selectedId), - ), - TextButton( - child: const Text('set marker icon'), - onPressed: selectedId == null - ? null - : () { - _getAssetIcon(context).then( - (BitmapDescriptor icon) { - _setMarkerIcon(selectedId, icon); - }, - ); - }, - ), - ], - ), - ], - ) - ], - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + child: const Text('Add'), + onPressed: _add, + ), + TextButton( + child: const Text('Remove'), + onPressed: + selectedId == null ? null : () => _remove(selectedId), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + TextButton( + child: const Text('change info'), + onPressed: + selectedId == null ? null : () => _changeInfo(selectedId), + ), + TextButton( + child: const Text('change info anchor'), + onPressed: selectedId == null + ? null + : () => _changeInfoAnchor(selectedId), + ), + TextButton( + child: const Text('change alpha'), + onPressed: + selectedId == null ? null : () => _changeAlpha(selectedId), + ), + TextButton( + child: const Text('change anchor'), + onPressed: + selectedId == null ? null : () => _changeAnchor(selectedId), + ), + TextButton( + child: const Text('toggle draggable'), + onPressed: selectedId == null + ? null + : () => _toggleDraggable(selectedId), + ), + TextButton( + child: const Text('toggle flat'), + onPressed: + selectedId == null ? null : () => _toggleFlat(selectedId), + ), + TextButton( + child: const Text('change position'), + onPressed: selectedId == null + ? null + : () => _changePosition(selectedId), + ), + TextButton( + child: const Text('change rotation'), + onPressed: selectedId == null + ? null + : () => _changeRotation(selectedId), + ), + TextButton( + child: const Text('toggle visible'), + onPressed: selectedId == null + ? null + : () => _toggleVisible(selectedId), + ), + TextButton( + child: const Text('change zIndex'), + onPressed: + selectedId == null ? null : () => _changeZIndex(selectedId), + ), + TextButton( + child: const Text('set marker icon'), + onPressed: selectedId == null + ? null + : () { + _getAssetIcon(context).then( + (BitmapDescriptor icon) { + _setMarkerIcon(selectedId, icon); + }, + ); + }, + ), + ], + ), + ], + ), + Visibility( + visible: markerPosition != null, + child: Container( + color: Colors.white70, + height: 30, + padding: EdgeInsets.only(left: 12, right: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + markerPosition == null + ? Container() + : Expanded(child: Text("lat: ${markerPosition!.latitude}")), + markerPosition == null + ? Container() + : Expanded(child: Text("lng: ${markerPosition!.longitude}")), + ], ), ), - ], - ); + ), + ]); } } From 9a7895301acc8b6464329f626441fd6e61767d9c Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 20 Oct 2021 11:03:04 -0400 Subject: [PATCH 355/364] [flutter_plugin_tools] Fix license-check on Windows (#4425) --- script/tool/CHANGELOG.md | 1 + .../tool/lib/src/license_check_command.dart | 10 ++++- .../tool/test/license_check_command_test.dart | 39 ++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 3b4ff8f226d8..bb4ca2390d78 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -13,6 +13,7 @@ - `license-check` now validates Kotlin files. - `pubspec-check` now checks that the description is of the pub-recommended length. +- Fix `license-check` when run on Windows with line ending conversion enabled. ## 0.7.1 diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart index 7165e985c059..d2c129ff7b48 100644 --- a/script/tool/lib/src/license_check_command.dart +++ b/script/tool/lib/src/license_check_command.dart @@ -206,7 +206,10 @@ class LicenseCheckCommand extends PluginCommand { for (final File file in codeFiles) { print('Checking ${file.path}'); - final String content = await file.readAsString(); + // On Windows, git may auto-convert line endings on checkout; this should + // still pass since they will be converted back on commit. + final String content = + (await file.readAsString()).replaceAll('\r\n', '\n'); final String firstParyLicense = firstPartyLicenseBlockByExtension[p.extension(file.path)] ?? @@ -244,7 +247,10 @@ class LicenseCheckCommand extends PluginCommand { for (final File file in files) { print('Checking ${file.path}'); - if (!file.readAsStringSync().contains(_fullBsdLicenseText)) { + // On Windows, git may auto-convert line endings on checkout; this should + // still pass since they will be converted back on commit. + final String contents = file.readAsStringSync().replaceAll('\r\n', '\n'); + if (!contents.contains(_fullBsdLicenseText)) { incorrectLicenseFiles.add(file); } } diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart index 5a8a90e9a674..e97274afd09e 100644 --- a/script/tool/test/license_check_command_test.dart +++ b/script/tool/test/license_check_command_test.dart @@ -48,12 +48,14 @@ void main() { 'Use of this source code is governed by a BSD-style license that can be', 'found in the LICENSE file.', ], + bool useCrlf = false, }) { final List lines = ['$prefix$comment$copyright']; for (final String line in license) { lines.add('$comment$line'); } - file.writeAsStringSync(lines.join('\n') + suffix + '\n'); + final String newline = useCrlf ? '\r\n' : '\n'; + file.writeAsStringSync(lines.join(newline) + suffix + newline); } test('looks at only expected extensions', () async { @@ -140,6 +142,23 @@ void main() { ])); }); + test('passes correct license blocks on Windows', () async { + final File checked = root.childFile('checked.cc'); + checked.createSync(); + _writeLicense(checked, useCrlf: true); + + final List output = + await runCapturingPrint(runner, ['license-check']); + + // Sanity check that the test did actually check a file. + expect( + output, + containsAllInOrder([ + contains('Checking checked.cc'), + contains('All files passed validation!'), + ])); + }); + test('handles the comment styles for all supported languages', () async { final File fileA = root.childFile('file_a.cc'); fileA.createSync(); @@ -406,6 +425,24 @@ void main() { ])); }); + test('passes correct LICENSE files on Windows', () async { + final File license = root.childFile('LICENSE'); + license.createSync(); + license + .writeAsStringSync(_correctLicenseFileText.replaceAll('\n', '\r\n')); + + final List output = + await runCapturingPrint(runner, ['license-check']); + + // Sanity check that the test did actually check the file. + expect( + output, + containsAllInOrder([ + contains('Checking LICENSE'), + contains('All files passed validation!'), + ])); + }); + test('fails if any first-party LICENSE files are incorrectly formatted', () async { final File license = root.childFile('LICENSE'); From fece8dcb1dbf756af15eb7193cfd393af9f764b0 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 20 Oct 2021 11:08:07 -0400 Subject: [PATCH 356/364] [ci] Always run all `format` steps (#4427) --- .cirrus.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index cb6d0dfd63fc..f223ce93dfd2 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -69,8 +69,8 @@ task: - dart pub run test - name: publishable env: - # TODO (mvanbeusekom): Temporary override to "stable" because of failure on "master". - # Remove override once https://github.com/dart-lang/pub/issues/3152 is resolved. + # TODO (mvanbeusekom): Temporary override to "stable" because of failure on "master". + # Remove override once https://github.com/dart-lang/pub/issues/3152 is resolved. CHANNEL: stable CHANGE_DESC: "$TMPDIR/change-description.txt" version_check_script: @@ -88,9 +88,10 @@ task: - fi publish_check_script: ./script/tool_runner.sh publish-check - name: format - format_script: ./script/tool_runner.sh format --fail-on-change - pubspec_script: ./script/tool_runner.sh pubspec-check - license_script: dart $PLUGIN_TOOL license-check + always: + format_script: ./script/tool_runner.sh format --fail-on-change + pubspec_script: ./script/tool_runner.sh pubspec-check + license_script: dart $PLUGIN_TOOL license-check - name: federated_safety # This check is only meaningful for PRs, as it validates changes # rather than state. From b8aea6d0e73d57f5e9656f35f998d55679598871 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 20 Oct 2021 19:23:06 +0200 Subject: [PATCH 357/364] [camera] Run iOS methods on UI thread by default (#4140) --- packages/camera/camera/CHANGELOG.md | 4 + .../ios/Runner.xcodeproj/project.pbxproj | 104 +++++---- .../ios/RunnerTests/CameraFocusTests.m | 12 +- .../RunnerTests/CameraMethodChannelTests.m | 48 ++++ .../ios/RunnerTests/CameraOrientationTests.m | 47 ++-- .../ios/RunnerTests/CameraPreviewPauseTests.m | 38 ++-- .../MockFLTThreadSafeFlutterResult.h | 25 +++ .../MockFLTThreadSafeFlutterResult.m | 27 +++ .../ThreadSafeFlutterResultTests.m | 122 +++++++++++ .../camera/camera/ios/Classes/CameraPlugin.m | 205 +++++++++--------- .../camera/ios/Classes/CameraPlugin_Test.h | 15 ++ .../ios/Classes/FLTThreadSafeFlutterResult.h | 51 +++++ .../ios/Classes/FLTThreadSafeFlutterResult.m | 58 +++++ .../camera/ios/Classes/camera-umbrella.h | 1 + packages/camera/camera/pubspec.yaml | 2 +- 15 files changed, 560 insertions(+), 199 deletions(-) create mode 100644 packages/camera/camera/example/ios/RunnerTests/CameraMethodChannelTests.m create mode 100644 packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h create mode 100644 packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m create mode 100644 packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m create mode 100644 packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h create mode 100644 packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index dc83a4fabca2..e6495a8fc796 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.4+3 + +* Fix registerTexture and result being called on background thread on iOS. + ## 0.9.4+2 * Updated package description; diff --git a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj index 8520bb00fb2f..feb789f2ecba 100644 --- a/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,8 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 033B94BE269C40A200B4DF97 /* CameraMethodChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 033B94BD269C40A200B4DF97 /* CameraMethodChannelTests.m */; }; 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB766A2665316900CE5A93 /* CameraFocusTests.m */; }; + 03F6F8B226CBB4670024B8D3 /* ThreadSafeFlutterResultTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03F6F8B126CBB4670024B8D3 /* ThreadSafeFlutterResultTests.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 236906D1621AE863A5B2E770 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 89D82918721FABF772705DB0 /* libPods-Runner.a */; }; + 25C3919135C3D981E6F800D0 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1944D8072499F3B5E7653D44 /* libPods-RunnerTests.a */; }; 334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; @@ -16,9 +20,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - A513685080F868CF2695CE75 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */; }; - D065CD815D405ECB22FB1BBA /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */; }; E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */; }; + F6EE622F2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m in Sources */ = {isa = PBXBuildFile; fileRef = F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,20 +48,22 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 033B94BD269C40A200B4DF97 /* CameraMethodChannelTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraMethodChannelTests.m; sourceTree = ""; }; 03BB76682665316900CE5A93 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 03BB766A2665316900CE5A93 /* CameraFocusTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraFocusTests.m; sourceTree = ""; }; 03BB766C2665316900CE5A93 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraOrientationTests.m; sourceTree = ""; }; + 03F6F8B126CBB4670024B8D3 /* ThreadSafeFlutterResultTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeFlutterResultTests.m; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 14AE82C910C2A12F2ECB2094 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 1944D8072499F3B5E7653D44 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 40D9DDFB3787960D28DF3FB3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 59848A7CA98C1FADF8840207 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; - 8F7D83D0CFC9B51065F87CE1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 89D82918721FABF772705DB0 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -67,9 +72,11 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 9C5CC6CAD53AD388B2694F3A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + A24F9E418BA48BCC7409B117 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPreviewPauseTests.m; sourceTree = ""; }; + F63F9EED27143B19002479BF /* MockFLTThreadSafeFlutterResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MockFLTThreadSafeFlutterResult.h; sourceTree = ""; }; + F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MockFLTThreadSafeFlutterResult.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -77,7 +84,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A513685080F868CF2695CE75 /* libPods-RunnerTests.a in Frameworks */, + 25C3919135C3D981E6F800D0 /* libPods-RunnerTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -85,7 +92,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D065CD815D405ECB22FB1BBA /* libPods-Runner.a in Frameworks */, + 236906D1621AE863A5B2E770 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -98,16 +105,20 @@ 03BB766A2665316900CE5A93 /* CameraFocusTests.m */, 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */, 03BB766C2665316900CE5A93 /* Info.plist */, + 033B94BD269C40A200B4DF97 /* CameraMethodChannelTests.m */, + 03F6F8B126CBB4670024B8D3 /* ThreadSafeFlutterResultTests.m */, E487C85F26D686A10034AC92 /* CameraPreviewPauseTests.m */, + F6EE622E2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m */, + F63F9EED27143B19002479BF /* MockFLTThreadSafeFlutterResult.h */, ); path = RunnerTests; sourceTree = ""; }; - 78D1009194BD06C03BED950D /* Frameworks */ = { + 3242FD2B467C15C62200632F /* Frameworks */ = { isa = PBXGroup; children = ( - 2A4F2DE74AE0C572296A00BF /* libPods-Runner.a */, - 5555DD51E06E67921CFA83DD /* libPods-RunnerTests.a */, + 89D82918721FABF772705DB0 /* libPods-Runner.a */, + 1944D8072499F3B5E7653D44 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -131,7 +142,7 @@ 03BB76692665316900CE5A93 /* RunnerTests */, 97C146EF1CF9000F007C117D /* Products */, FD386F00E98D73419C929072 /* Pods */, - 78D1009194BD06C03BED950D /* Frameworks */, + 3242FD2B467C15C62200632F /* Frameworks */, ); sourceTree = ""; }; @@ -171,10 +182,10 @@ FD386F00E98D73419C929072 /* Pods */ = { isa = PBXGroup; children = ( - 8F7D83D0CFC9B51065F87CE1 /* Pods-Runner.debug.xcconfig */, - A4725B4F24805CD3CA67828F /* Pods-Runner.release.xcconfig */, - 40D9DDFB3787960D28DF3FB3 /* Pods-RunnerTests.debug.xcconfig */, - D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */, + 59848A7CA98C1FADF8840207 /* Pods-Runner.debug.xcconfig */, + 14AE82C910C2A12F2ECB2094 /* Pods-Runner.release.xcconfig */, + 9C5CC6CAD53AD388B2694F3A /* Pods-RunnerTests.debug.xcconfig */, + A24F9E418BA48BCC7409B117 /* Pods-RunnerTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -186,7 +197,7 @@ isa = PBXNativeTarget; buildConfigurationList = 03BB76712665316900CE5A93 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 604FC00FF5713F40F2A4441D /* [CP] Check Pods Manifest.lock */, + 422786A96136AA9087A2041B /* [CP] Check Pods Manifest.lock */, 03BB76642665316900CE5A93 /* Sources */, 03BB76652665316900CE5A93 /* Frameworks */, 03BB76662665316900CE5A93 /* Resources */, @@ -205,7 +216,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 1D0D227A6719C1144CAE5AB5 /* [CP] Check Pods Manifest.lock */, + 9872F2A25E8A171A111468CD /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -282,7 +293,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1D0D227A6719C1144CAE5AB5 /* [CP] Check Pods Manifest.lock */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 422786A96136AA9087A2041B /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -297,28 +322,28 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); - name = "Thin Binary"; + name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - 604FC00FF5713F40F2A4441D /* [CP] Check Pods Manifest.lock */ = { + 9872F2A25E8A171A111468CD /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -333,27 +358,13 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -361,8 +372,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 03F6F8B226CBB4670024B8D3 /* ThreadSafeFlutterResultTests.m in Sources */, + 033B94BE269C40A200B4DF97 /* CameraMethodChannelTests.m in Sources */, 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */, + F6EE622F2710A6FC00905E4A /* MockFLTThreadSafeFlutterResult.m in Sources */, 334733EA2668111C00DCC49E /* CameraOrientationTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -409,7 +423,7 @@ /* Begin XCBuildConfiguration section */ 03BB766F2665316900CE5A93 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 40D9DDFB3787960D28DF3FB3 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 9C5CC6CAD53AD388B2694F3A /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -419,6 +433,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -434,7 +449,7 @@ }; 03BB76702665316900CE5A93 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D1FF8C34CA9E9BE702C5EC06 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = A24F9E418BA48BCC7409B117 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -444,6 +459,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -567,6 +583,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -588,6 +605,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m index 27537e7ebdac..fdc2be9901a4 100644 --- a/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m +++ b/packages/camera/camera/example/ios/RunnerTests/CameraFocusTests.m @@ -19,7 +19,7 @@ @interface FLTCam : NSObject +#import "MockFLTThreadSafeFlutterResult.h" + +@interface CameraMethodChannelTests : XCTestCase +@end + +@implementation CameraMethodChannelTests + +- (void)testCreate_ShouldCallResultOnMainThread { + CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; + + XCTestExpectation *expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + // Set up mocks for initWithCameraName method + id avCaptureDeviceInputMock = OCMClassMock([AVCaptureDeviceInput class]); + OCMStub([avCaptureDeviceInputMock deviceInputWithDevice:[OCMArg any] error:[OCMArg anyObjectRef]]) + .andReturn([AVCaptureInput alloc]); + + id avCaptureSessionMock = OCMClassMock([AVCaptureSession class]); + OCMStub([avCaptureSessionMock alloc]).andReturn(avCaptureSessionMock); + OCMStub([avCaptureSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); + + MockFLTThreadSafeFlutterResult *resultObject = + [[MockFLTThreadSafeFlutterResult alloc] initWithExpectation:expectation]; + + // Set up method call + FlutterMethodCall *call = [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"resolutionPreset" : @"medium", @"enableAudio" : @(1)}]; + + [camera handleMethodCallAsync:call result:resultObject]; + + // Verify the result + NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult; + XCTAssertNotNil(dictionaryResult); + XCTAssert([[dictionaryResult allKeys] containsObject:@"cameraId"]); +} + +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m index 246eab90a919..efd65a47dff8 100644 --- a/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m +++ b/packages/camera/camera/example/ios/RunnerTests/CameraOrientationTests.m @@ -10,46 +10,52 @@ #import @interface CameraOrientationTests : XCTestCase -@property(strong, nonatomic) id mockMessenger; -@property(strong, nonatomic) CameraPlugin *cameraPlugin; @end @implementation CameraOrientationTests -- (void)setUp { - [super setUp]; - - self.mockMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); - self.cameraPlugin = [[CameraPlugin alloc] initWithRegistry:nil messenger:self.mockMessenger]; -} - - (void)testOrientationNotifications { - id mockMessenger = self.mockMessenger; + id mockMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); + CameraPlugin *cameraPlugin = [[CameraPlugin alloc] initWithRegistry:nil messenger:mockMessenger]; + [mockMessenger setExpectationOrderMatters:YES]; - [self rotate:UIDeviceOrientationPortraitUpsideDown expectedChannelOrientation:@"portraitDown"]; - [self rotate:UIDeviceOrientationPortrait expectedChannelOrientation:@"portraitUp"]; - [self rotate:UIDeviceOrientationLandscapeRight expectedChannelOrientation:@"landscapeLeft"]; - [self rotate:UIDeviceOrientationLandscapeLeft expectedChannelOrientation:@"landscapeRight"]; + [self rotate:UIDeviceOrientationPortraitUpsideDown + expectedChannelOrientation:@"portraitDown" + cameraPlugin:cameraPlugin + messenger:mockMessenger]; + [self rotate:UIDeviceOrientationPortrait + expectedChannelOrientation:@"portraitUp" + cameraPlugin:cameraPlugin + messenger:mockMessenger]; + [self rotate:UIDeviceOrientationLandscapeRight + expectedChannelOrientation:@"landscapeLeft" + cameraPlugin:cameraPlugin + messenger:mockMessenger]; + [self rotate:UIDeviceOrientationLandscapeLeft + expectedChannelOrientation:@"landscapeRight" + cameraPlugin:cameraPlugin + messenger:mockMessenger]; OCMReject([mockMessenger sendOnChannel:[OCMArg any] message:[OCMArg any]]); // No notification when flat. - [self.cameraPlugin + [cameraPlugin orientationChanged:[self createMockNotificationForOrientation:UIDeviceOrientationFaceUp]]; // No notification when facedown. - [self.cameraPlugin + [cameraPlugin orientationChanged:[self createMockNotificationForOrientation:UIDeviceOrientationFaceDown]]; OCMVerifyAll(mockMessenger); } - (void)rotate:(UIDeviceOrientation)deviceOrientation - expectedChannelOrientation:(NSString *)channelOrientation { - id mockMessenger = self.mockMessenger; + expectedChannelOrientation:(NSString *)channelOrientation + cameraPlugin:(CameraPlugin *)cameraPlugin + messenger:(NSObject *)messenger { XCTestExpectation *orientationExpectation = [self expectationWithDescription:channelOrientation]; - OCMExpect([mockMessenger + OCMExpect([messenger sendOnChannel:[OCMArg any] message:[OCMArg checkWithBlock:^BOOL(NSData *data) { NSObject *codec = [FlutterStandardMethodCodec sharedInstance]; @@ -60,8 +66,7 @@ - (void)rotate:(UIDeviceOrientation)deviceOrientation [methodCall.arguments isEqualToDictionary:@{@"orientation" : channelOrientation}]; }]]); - [self.cameraPlugin - orientationChanged:[self createMockNotificationForOrientation:deviceOrientation]]; + [cameraPlugin orientationChanged:[self createMockNotificationForOrientation:deviceOrientation]]; [self waitForExpectationsWithTimeout:30.0 handler:nil]; } diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m index 549b40a52e46..eb6c0079322c 100644 --- a/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m +++ b/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m @@ -6,45 +6,37 @@ @import XCTest; @import AVFoundation; #import +#import "MockFLTThreadSafeFlutterResult.h" @interface FLTCam : NSObject @property(assign, nonatomic) BOOL isPreviewPaused; -- (void)pausePreviewWithResult:(FlutterResult)result; -- (void)resumePreviewWithResult:(FlutterResult)result; + +- (void)pausePreviewWithResult:(FLTThreadSafeFlutterResult *)result; + +- (void)resumePreviewWithResult:(FLTThreadSafeFlutterResult *)result; @end @interface CameraPreviewPauseTests : XCTestCase -@property(readonly, nonatomic) FLTCam* camera; @end @implementation CameraPreviewPauseTests -- (void)setUp { - _camera = [[FLTCam alloc] init]; -} - - (void)testPausePreviewWithResult_shouldPausePreview { - XCTestExpectation* resultExpectation = - [self expectationWithDescription:@"Succeeding result with nil value"]; - [_camera pausePreviewWithResult:^void(id _Nullable result) { - XCTAssertNil(result); - [resultExpectation fulfill]; - }]; - [self waitForExpectationsWithTimeout:2.0 handler:nil]; - XCTAssertTrue(_camera.isPreviewPaused); + FLTCam *camera = [[FLTCam alloc] init]; + MockFLTThreadSafeFlutterResult *resultObject = [[MockFLTThreadSafeFlutterResult alloc] init]; + + [camera pausePreviewWithResult:resultObject]; + XCTAssertTrue(camera.isPreviewPaused); } - (void)testResumePreviewWithResult_shouldResumePreview { - XCTestExpectation* resultExpectation = - [self expectationWithDescription:@"Succeeding result with nil value"]; - [_camera resumePreviewWithResult:^void(id _Nullable result) { - XCTAssertNil(result); - [resultExpectation fulfill]; - }]; - [self waitForExpectationsWithTimeout:2.0 handler:nil]; - XCTAssertFalse(_camera.isPreviewPaused); + FLTCam *camera = [[FLTCam alloc] init]; + MockFLTThreadSafeFlutterResult *resultObject = [[MockFLTThreadSafeFlutterResult alloc] init]; + + [camera resumePreviewWithResult:resultObject]; + XCTAssertFalse(camera.isPreviewPaused); } @end diff --git a/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h new file mode 100644 index 000000000000..8685f3fd610b --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h @@ -0,0 +1,25 @@ +// Copyright 2013 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. + +#ifndef MockFLTThreadSafeFlutterResult_h +#define MockFLTThreadSafeFlutterResult_h + +/** + * Extends FLTThreadSafeFlutterResult to give tests the ability to wait on the result and + * read the received result. + */ +@interface MockFLTThreadSafeFlutterResult : FLTThreadSafeFlutterResult +@property(readonly, nonatomic, nonnull) XCTestExpectation *expectation; +@property(nonatomic, nullable) id receivedResult; + +/** + * Initializes the MockFLTThreadSafeFlutterResult with an expectation. + * + * The expectation is fullfilled when a result is called allowing tests to await the result in an + * asynchronous manner. + */ +- (nonnull instancetype)initWithExpectation:(nonnull XCTestExpectation *)expectation; +@end + +#endif /* MockFLTThreadSafeFlutterResult_h */ diff --git a/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m new file mode 100644 index 000000000000..da2fc2d936ba --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m @@ -0,0 +1,27 @@ +// Copyright 2013 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 camera; +@import XCTest; + +#import "MockFLTThreadSafeFlutterResult.h" + +@implementation MockFLTThreadSafeFlutterResult + +- (instancetype)initWithExpectation:(XCTestExpectation *)expectation { + self = [super init]; + _expectation = expectation; + return self; +} + +- (void)sendSuccessWithData:(id)data { + self.receivedResult = data; + [self.expectation fulfill]; +} + +- (void)sendSuccess { + self.receivedResult = nil; + [self.expectation fulfill]; +} +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m b/packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m new file mode 100644 index 000000000000..8cd4b8bc8c2a --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m @@ -0,0 +1,122 @@ +// Copyright 2013 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 camera; +@import XCTest; + +@interface ThreadSafeFlutterResultTests : XCTestCase +@end + +@implementation ThreadSafeFlutterResultTests +- (void)testAsyncSendSuccess_ShouldCallResultOnMainThread { + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert(NSThread.isMainThread); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendSuccess]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSyncSendSuccess_ShouldCallResultOnMainThread { + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert(NSThread.isMainThread); + [expectation fulfill]; + }]; + [threadSafeFlutterResult sendSuccess]; + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendNotImplemented_ShouldSendNotImplementedToFlutterResult { + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert([result isKindOfClass:FlutterMethodNotImplemented.class]); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendNotImplemented]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendErrorDetails_ShouldSendErrorToFlutterResult { + NSString* errorCode = @"errorCode"; + NSString* errorMessage = @"message"; + NSString* errorDetails = @"error details"; + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert([result isKindOfClass:FlutterError.class]); + FlutterError* error = (FlutterError*)result; + XCTAssertEqualObjects(error.code, errorCode); + XCTAssertEqualObjects(error.message, errorMessage); + XCTAssertEqualObjects(error.details, errorDetails); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendErrorWithCode:errorCode message:errorMessage details:errorDetails]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendNSError_ShouldSendErrorToFlutterResult { + NSError* originalError = [[NSError alloc] initWithDomain:NSURLErrorDomain code:404 userInfo:nil]; + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert([result isKindOfClass:FlutterError.class]); + FlutterError* error = (FlutterError*)result; + NSString* constructedErrorCode = + [NSString stringWithFormat:@"Error %d", (int)originalError.code]; + XCTAssertEqualObjects(error.code, constructedErrorCode); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendError:originalError]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendResult_ShouldSendResultToFlutterResult { + NSString* resultData = @"resultData"; + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssertEqualObjects(result, resultData); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendSuccessWithData:resultData]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} +@end diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index 78c5a34ab05a..2c12081da807 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -10,16 +10,11 @@ #import #import #import - -static FlutterError *getFlutterError(NSError *error) { - return [FlutterError errorWithCode:[NSString stringWithFormat:@"Error %d", (int)error.code] - message:error.localizedDescription - details:error.domain]; -} +#import "FLTThreadSafeFlutterResult.h" @interface FLTSavePhotoDelegate : NSObject @property(readonly, nonatomic) NSString *path; -@property(readonly, nonatomic) FlutterResult result; +@property(readonly, nonatomic) FLTThreadSafeFlutterResult *result; @end @interface FLTImageStreamHandler : NSObject @@ -45,7 +40,7 @@ @implementation FLTSavePhotoDelegate { FLTSavePhotoDelegate *selfReference; } -- initWithPath:(NSString *)path result:(FlutterResult)result { +- initWithPath:(NSString *)path result:(FLTThreadSafeFlutterResult *)result { self = [super init]; NSAssert(self, @"super init cannot be nil"); _path = path; @@ -62,7 +57,7 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output error:(NSError *)error API_AVAILABLE(ios(10)) { selfReference = nil; if (error) { - _result(getFlutterError(error)); + [_result sendError:error]; return; } @@ -74,10 +69,10 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output bool success = [data writeToFile:_path atomically:YES]; if (!success) { - _result([FlutterError errorWithCode:@"IOError" message:@"Unable to write file" details:nil]); + [_result sendErrorWithCode:@"IOError" message:@"Unable to write file" details:nil]; return; } - _result(_path); + [_result sendSuccessWithData:_path]; } - (void)captureOutput:(AVCapturePhotoOutput *)output @@ -85,7 +80,7 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output error:(NSError *)error API_AVAILABLE(ios(11.0)) { selfReference = nil; if (error) { - _result(getFlutterError(error)); + [_result sendError:error]; return; } @@ -93,10 +88,10 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output bool success = [photoData writeToFile:_path atomically:YES]; if (!success) { - _result([FlutterError errorWithCode:@"IOError" message:@"Unable to write file" details:nil]); + [_result sendErrorWithCode:@"IOError" message:@"Unable to write file" details:nil]; return; } - _result(_path); + [_result sendSuccessWithData:_path]; } @end @@ -460,7 +455,7 @@ - (void)updateOrientation:(UIDeviceOrientation)orientation } } -- (void)captureToFile:(FlutterResult)result API_AVAILABLE(ios(10)) { +- (void)captureToFile:(FLTThreadSafeFlutterResult *)result API_AVAILABLE(ios(10)) { AVCapturePhotoSettings *settings = [AVCapturePhotoSettings photoSettings]; if (_resolutionPreset == max) { [settings setHighResolutionPhotoEnabled:YES]; @@ -476,7 +471,7 @@ - (void)captureToFile:(FlutterResult)result API_AVAILABLE(ios(10)) { prefix:@"CAP_" error:error]; if (error) { - result(getFlutterError(error)); + [result sendError:error]; return; } @@ -816,7 +811,7 @@ - (CVPixelBufferRef)copyPixelBuffer { return pixelBuffer; } -- (void)startVideoRecordingWithResult:(FlutterResult)result { +- (void)startVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result { if (!_isRecording) { NSError *error; _videoRecordingPath = [self getTemporaryFilePathWithExtension:@"mp4" @@ -824,11 +819,11 @@ - (void)startVideoRecordingWithResult:(FlutterResult)result { prefix:@"REC_" error:error]; if (error) { - result(getFlutterError(error)); + [result sendError:error]; return; } if (![self setupWriterForPath:_videoRecordingPath]) { - result([FlutterError errorWithCode:@"IOError" message:@"Setup Writer Failed" details:nil]); + [result sendErrorWithCode:@"IOError" message:@"Setup Writer Failed" details:nil]; return; } _isRecording = YES; @@ -837,13 +832,13 @@ - (void)startVideoRecordingWithResult:(FlutterResult)result { _audioTimeOffset = CMTimeMake(0, 1); _videoIsDisconnected = NO; _audioIsDisconnected = NO; - result(nil); + [result sendSuccess]; } else { - result([FlutterError errorWithCode:@"Error" message:@"Video is already recording" details:nil]); + [result sendErrorWithCode:@"Error" message:@"Video is already recording" details:nil]; } } -- (void)stopVideoRecordingWithResult:(FlutterResult)result { +- (void)stopVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result { if (_isRecording) { _isRecording = NO; @@ -851,12 +846,12 @@ - (void)stopVideoRecordingWithResult:(FlutterResult)result { [_videoWriter finishWritingWithCompletionHandler:^{ if (self->_videoWriter.status == AVAssetWriterStatusCompleted) { [self updateOrientation]; - result(self->_videoRecordingPath); + [result sendSuccessWithData:self->_videoRecordingPath]; self->_videoRecordingPath = nil; } else { - result([FlutterError errorWithCode:@"IOError" - message:@"AVAssetWriter could not finish writing!" - details:nil]); + [result sendErrorWithCode:@"IOError" + message:@"AVAssetWriter could not finish writing!" + details:nil]; } }]; } @@ -865,29 +860,29 @@ - (void)stopVideoRecordingWithResult:(FlutterResult)result { [NSError errorWithDomain:NSCocoaErrorDomain code:NSURLErrorResourceUnavailable userInfo:@{NSLocalizedDescriptionKey : @"Video is not recording!"}]; - result(getFlutterError(error)); + [result sendError:error]; } } -- (void)pauseVideoRecordingWithResult:(FlutterResult)result { +- (void)pauseVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result { _isRecordingPaused = YES; _videoIsDisconnected = YES; _audioIsDisconnected = YES; - result(nil); + [result sendSuccess]; } -- (void)resumeVideoRecordingWithResult:(FlutterResult)result { +- (void)resumeVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result { _isRecordingPaused = NO; - result(nil); + [result sendSuccess]; } -- (void)lockCaptureOrientationWithResult:(FlutterResult)result +- (void)lockCaptureOrientationWithResult:(FLTThreadSafeFlutterResult *)result orientation:(NSString *)orientationStr { UIDeviceOrientation orientation; @try { orientation = getUIDeviceOrientationForString(orientationStr); } @catch (NSError *e) { - result(getFlutterError(e)); + [result sendError:e]; return; } @@ -896,34 +891,34 @@ - (void)lockCaptureOrientationWithResult:(FlutterResult)result [self updateOrientation]; } - result(nil); + [result sendSuccess]; } -- (void)unlockCaptureOrientationWithResult:(FlutterResult)result { +- (void)unlockCaptureOrientationWithResult:(FLTThreadSafeFlutterResult *)result { _lockedCaptureOrientation = UIDeviceOrientationUnknown; [self updateOrientation]; - result(nil); + [result sendSuccess]; } -- (void)setFlashModeWithResult:(FlutterResult)result mode:(NSString *)modeStr { +- (void)setFlashModeWithResult:(FLTThreadSafeFlutterResult *)result mode:(NSString *)modeStr { FlashMode mode; @try { mode = getFlashModeForString(modeStr); } @catch (NSError *e) { - result(getFlutterError(e)); + [result sendError:e]; return; } if (mode == FlashModeTorch) { if (!_captureDevice.hasTorch) { - result([FlutterError errorWithCode:@"setFlashModeFailed" - message:@"Device does not support torch mode" - details:nil]); + [result sendErrorWithCode:@"setFlashModeFailed" + message:@"Device does not support torch mode" + details:nil]; return; } if (!_captureDevice.isTorchAvailable) { - result([FlutterError errorWithCode:@"setFlashModeFailed" - message:@"Torch mode is currently not available" - details:nil]); + [result sendErrorWithCode:@"setFlashModeFailed" + message:@"Torch mode is currently not available" + details:nil]; return; } if (_captureDevice.torchMode != AVCaptureTorchModeOn) { @@ -933,17 +928,17 @@ - (void)setFlashModeWithResult:(FlutterResult)result mode:(NSString *)modeStr { } } else { if (!_captureDevice.hasFlash) { - result([FlutterError errorWithCode:@"setFlashModeFailed" - message:@"Device does not have flash capabilities" - details:nil]); + [result sendErrorWithCode:@"setFlashModeFailed" + message:@"Device does not have flash capabilities" + details:nil]; return; } AVCaptureFlashMode avFlashMode = getAVCaptureFlashModeForFlashMode(mode); if (![_capturePhotoOutput.supportedFlashModes containsObject:[NSNumber numberWithInt:((int)avFlashMode)]]) { - result([FlutterError errorWithCode:@"setFlashModeFailed" - message:@"Device does not support this specific flash mode" - details:nil]); + [result sendErrorWithCode:@"setFlashModeFailed" + message:@"Device does not support this specific flash mode" + details:nil]; return; } if (_captureDevice.torchMode != AVCaptureTorchModeOff) { @@ -953,20 +948,20 @@ - (void)setFlashModeWithResult:(FlutterResult)result mode:(NSString *)modeStr { } } _flashMode = mode; - result(nil); + [result sendSuccess]; } -- (void)setExposureModeWithResult:(FlutterResult)result mode:(NSString *)modeStr { +- (void)setExposureModeWithResult:(FLTThreadSafeFlutterResult *)result mode:(NSString *)modeStr { ExposureMode mode; @try { mode = getExposureModeForString(modeStr); } @catch (NSError *e) { - result(getFlutterError(e)); + [result sendError:e]; return; } _exposureMode = mode; [self applyExposureMode]; - result(nil); + [result sendSuccess]; } - (void)applyExposureMode { @@ -986,17 +981,17 @@ - (void)applyExposureMode { [_captureDevice unlockForConfiguration]; } -- (void)setFocusModeWithResult:(FlutterResult)result mode:(NSString *)modeStr { +- (void)setFocusModeWithResult:(FLTThreadSafeFlutterResult *)result mode:(NSString *)modeStr { FocusMode mode; @try { mode = getFocusModeForString(modeStr); } @catch (NSError *e) { - result(getFlutterError(e)); + [result sendError:e]; return; } _focusMode = mode; [self applyFocusMode]; - result(nil); + [result sendSuccess]; } - (void)applyFocusMode { @@ -1036,14 +1031,14 @@ - (void)applyFocusMode:(FocusMode)focusMode onDevice:(AVCaptureDevice *)captureD [captureDevice unlockForConfiguration]; } -- (void)pausePreviewWithResult:(FlutterResult)result { +- (void)pausePreviewWithResult:(FLTThreadSafeFlutterResult *)result { _isPreviewPaused = true; - result(nil); + [result sendSuccess]; } -- (void)resumePreviewWithResult:(FlutterResult)result { +- (void)resumePreviewWithResult:(FLTThreadSafeFlutterResult *)result { _isPreviewPaused = false; - result(nil); + [result sendSuccess]; } - (CGPoint)getCGPointForCoordsWithOrientation:(UIDeviceOrientation)orientation @@ -1071,11 +1066,11 @@ - (CGPoint)getCGPointForCoordsWithOrientation:(UIDeviceOrientation)orientation return CGPointMake(x, y); } -- (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y { +- (void)setExposurePointWithResult:(FLTThreadSafeFlutterResult *)result x:(double)x y:(double)y { if (!_captureDevice.isExposurePointOfInterestSupported) { - result([FlutterError errorWithCode:@"setExposurePointFailed" - message:@"Device does not have exposure point capabilities" - details:nil]); + [result sendErrorWithCode:@"setExposurePointFailed" + message:@"Device does not have exposure point capabilities" + details:nil]; return; } UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation]; @@ -1086,14 +1081,14 @@ - (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y [_captureDevice unlockForConfiguration]; // Retrigger auto exposure [self applyExposureMode]; - result(nil); + [result sendSuccess]; } -- (void)setFocusPointWithResult:(FlutterResult)result x:(double)x y:(double)y { +- (void)setFocusPointWithResult:(FLTThreadSafeFlutterResult *)result x:(double)x y:(double)y { if (!_captureDevice.isFocusPointOfInterestSupported) { - result([FlutterError errorWithCode:@"setFocusPointFailed" - message:@"Device does not have focus point capabilities" - details:nil]); + [result sendErrorWithCode:@"setFocusPointFailed" + message:@"Device does not have focus point capabilities" + details:nil]; return; } UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation]; @@ -1105,15 +1100,14 @@ - (void)setFocusPointWithResult:(FlutterResult)result x:(double)x y:(double)y { [_captureDevice unlockForConfiguration]; // Retrigger auto focus [self applyFocusMode]; - - result(nil); + [result sendSuccess]; } -- (void)setExposureOffsetWithResult:(FlutterResult)result offset:(double)offset { +- (void)setExposureOffsetWithResult:(FLTThreadSafeFlutterResult *)result offset:(double)offset { [_captureDevice lockForConfiguration:nil]; [_captureDevice setExposureTargetBias:offset completionHandler:nil]; [_captureDevice unlockForConfiguration]; - result(@(offset)); + [result sendSuccessWithData:@(offset)]; } - (void)startImageStreamWithMessenger:(NSObject *)messenger { @@ -1141,19 +1135,18 @@ - (void)stopImageStream { } } -- (void)getMaxZoomLevelWithResult:(FlutterResult)result { +- (void)getMaxZoomLevelWithResult:(FLTThreadSafeFlutterResult *)result { CGFloat maxZoomFactor = [self getMaxAvailableZoomFactor]; - result([NSNumber numberWithFloat:maxZoomFactor]); + [result sendSuccessWithData:[NSNumber numberWithFloat:maxZoomFactor]]; } -- (void)getMinZoomLevelWithResult:(FlutterResult)result { +- (void)getMinZoomLevelWithResult:(FLTThreadSafeFlutterResult *)result { CGFloat minZoomFactor = [self getMinAvailableZoomFactor]; - - result([NSNumber numberWithFloat:minZoomFactor]); + [result sendSuccessWithData:[NSNumber numberWithFloat:minZoomFactor]]; } -- (void)setZoomLevel:(CGFloat)zoom Result:(FlutterResult)result { +- (void)setZoomLevel:(CGFloat)zoom Result:(FLTThreadSafeFlutterResult *)result { CGFloat maxAvailableZoomFactor = [self getMaxAvailableZoomFactor]; CGFloat minAvailableZoomFactor = [self getMinAvailableZoomFactor]; @@ -1161,22 +1154,20 @@ - (void)setZoomLevel:(CGFloat)zoom Result:(FlutterResult)result { NSString *errorMessage = [NSString stringWithFormat:@"Zoom level out of bounds (zoom level should be between %f and %f).", minAvailableZoomFactor, maxAvailableZoomFactor]; - FlutterError *error = [FlutterError errorWithCode:@"ZOOM_ERROR" - message:errorMessage - details:nil]; - result(error); + + [result sendErrorWithCode:@"ZOOM_ERROR" message:errorMessage details:nil]; return; } NSError *error = nil; if (![_captureDevice lockForConfiguration:&error]) { - result(getFlutterError(error)); + [result sendError:error]; return; } _captureDevice.videoZoomFactor = zoom; [_captureDevice unlockForConfiguration]; - result(nil); + [result sendSuccess]; } - (CGFloat)getMinAvailableZoomFactor { @@ -1303,6 +1294,7 @@ @interface CameraPlugin () @implementation CameraPlugin { dispatch_queue_t _dispatchQueue; } + + (void)registerWithRegistrar:(NSObject *)registrar { FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/camera" @@ -1366,11 +1358,15 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result // Invoke the plugin on another dispatch queue to avoid blocking the UI. dispatch_async(_dispatchQueue, ^{ - [self handleMethodCallAsync:call result:result]; + FLTThreadSafeFlutterResult *threadSafeResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:result]; + + [self handleMethodCallAsync:call result:threadSafeResult]; }); } -- (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)result { +- (void)handleMethodCallAsync:(FlutterMethodCall *)call + result:(FLTThreadSafeFlutterResult *)result { if ([@"availableCameras" isEqualToString:call.method]) { if (@available(iOS 10.0, *)) { AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession @@ -1399,9 +1395,9 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re @"sensorOrientation" : @90, }]; } - result(reply); + [result sendSuccessWithData:reply]; } else { - result(FlutterMethodNotImplemented); + [result sendNotImplemented]; } } else if ([@"create" isEqualToString:call.method]) { NSString *cameraName = call.arguments[@"cameraName"]; @@ -1416,24 +1412,23 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re error:&error]; if (error) { - result(getFlutterError(error)); + [result sendError:error]; } else { if (_camera) { [_camera close]; } - int64_t textureId = [_registry registerTexture:cam]; + int64_t textureId = [self.registry registerTexture:cam]; _camera = cam; - - result(@{ + [result sendSuccessWithData:@{ @"cameraId" : @(textureId), - }); + }]; } } else if ([@"startImageStream" isEqualToString:call.method]) { [_camera startImageStreamWithMessenger:_messenger]; - result(nil); + [result sendSuccess]; } else if ([@"stopImageStream" isEqualToString:call.method]) { [_camera stopImageStream]; - result(nil); + [result sendSuccess]; } else { NSDictionary *argsMap = call.arguments; NSUInteger cameraId = ((NSNumber *)argsMap[@"cameraId"]).unsignedIntegerValue; @@ -1465,21 +1460,21 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re }]; [self sendDeviceOrientation:[UIDevice currentDevice].orientation]; [_camera start]; - result(nil); + [result sendSuccess]; } else if ([@"takePicture" isEqualToString:call.method]) { if (@available(iOS 10.0, *)) { [_camera captureToFile:result]; } else { - result(FlutterMethodNotImplemented); + [result sendNotImplemented]; } } else if ([@"dispose" isEqualToString:call.method]) { [_registry unregisterTexture:cameraId]; [_camera close]; _dispatchQueue = nil; - result(nil); + [result sendSuccess]; } else if ([@"prepareForVideoRecording" isEqualToString:call.method]) { [_camera setUpCaptureSessionForAudio]; - result(nil); + [result sendSuccess]; } else if ([@"startVideoRecording" isEqualToString:call.method]) { [_camera startVideoRecordingWithResult:result]; } else if ([@"stopVideoRecording" isEqualToString:call.method]) { @@ -1509,11 +1504,11 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re } [_camera setExposurePointWithResult:result x:x y:y]; } else if ([@"getMinExposureOffset" isEqualToString:call.method]) { - result(@(_camera.captureDevice.minExposureTargetBias)); + [result sendSuccessWithData:@(_camera.captureDevice.minExposureTargetBias)]; } else if ([@"getMaxExposureOffset" isEqualToString:call.method]) { - result(@(_camera.captureDevice.maxExposureTargetBias)); + [result sendSuccessWithData:@(_camera.captureDevice.maxExposureTargetBias)]; } else if ([@"getExposureOffsetStepSize" isEqualToString:call.method]) { - result(@(0.0)); + [result sendSuccessWithData:@(0.0)]; } else if ([@"setExposureOffset" isEqualToString:call.method]) { [_camera setExposureOffsetWithResult:result offset:((NSNumber *)call.arguments[@"offset"]).doubleValue]; @@ -1537,7 +1532,7 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re } else if ([@"resumePreview" isEqualToString:call.method]) { [_camera resumePreviewWithResult:result]; } else { - result(FlutterMethodNotImplemented); + [result sendNotImplemented]; } } } diff --git a/packages/camera/camera/ios/Classes/CameraPlugin_Test.h b/packages/camera/camera/ios/Classes/CameraPlugin_Test.h index 6952ae0a4aaa..afbf6864a1f8 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin_Test.h +++ b/packages/camera/camera/ios/Classes/CameraPlugin_Test.h @@ -5,15 +5,30 @@ // This header is available in the Test module. Import via "@import camera.Test;" #import +#import /// Methods exposed for unit testing. @interface CameraPlugin () +/// Inject @p FlutterTextureRegistry and @p FlutterBinaryMessenger for unit testing. - (instancetype)initWithRegistry:(NSObject *)registry messenger:(NSObject *)messenger NS_DESIGNATED_INITIALIZER; + +/// Hide the default public constructor. - (instancetype)init NS_UNAVAILABLE; +/// Handles `FlutterMethodCall`s and ensures result is send on the main dispatch queue. +/// +/// @param call The method call command object. +/// @param result A wrapper around the `FlutterResult` callback which ensures the callback is called +/// on the main dispatch queue. +- (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FLTThreadSafeFlutterResult *)result; + +/// Called by the @c NSNotificationManager each time the device's orientation is changed. +/// +/// @param notification @c NSNotification instance containing a reference to the `UIDevice` object +/// that triggered the orientation change. - (void)orientationChanged:(NSNotification *)notification; @end diff --git a/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h new file mode 100644 index 000000000000..f290ca0fcd05 --- /dev/null +++ b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h @@ -0,0 +1,51 @@ +// Copyright 2013 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 + +/** + * Wrapper for FlutterResult that always delivers the result on the main thread. + */ +@interface FLTThreadSafeFlutterResult : NSObject + +/** + * Gets the original FlutterResult object wrapped by this FLTThreadSafeFlutterResult instance. + */ +@property(readonly, nonatomic, nonnull) FlutterResult flutterResult; + +/** + * Initializes with a FlutterResult object. + * @param result The FlutterResult object that the result will be given to. + */ +- (nonnull instancetype)initWithResult:(nonnull FlutterResult)result; + +/** + * Sends a successful result without any data. + */ +- (void)sendSuccess; + +/** + * Sends a successful result with data. + * @param data Result data that is send to the Flutter Dart side. + */ +- (void)sendSuccessWithData:(nonnull id)data; + +/** + * Sends an NSError as result + * @param error Error that will be send as FlutterError. + */ +- (void)sendError:(nonnull NSError*)error; + +/** + * Sends a FlutterError as result. + */ +- (void)sendErrorWithCode:(nonnull NSString*)code + message:(nullable NSString*)message + details:(nullable id)details; + +/** + * Sends FlutterMethodNotImplemented as result. + */ +- (void)sendNotImplemented; +@end diff --git a/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m new file mode 100644 index 000000000000..caa4788d8dc8 --- /dev/null +++ b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m @@ -0,0 +1,58 @@ +// Copyright 2013 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 "FLTThreadSafeFlutterResult.h" +#import + +@implementation FLTThreadSafeFlutterResult { +} + +- (id)initWithResult:(FlutterResult)result { + self = [super init]; + if (!self) { + return nil; + } + _flutterResult = result; + return self; +} + +- (void)sendSuccess { + [self send:nil]; +} + +- (void)sendSuccessWithData:(id)data { + [self send:data]; +} + +- (void)sendError:(NSError*)error { + [self sendErrorWithCode:[NSString stringWithFormat:@"Error %d", (int)error.code] + message:error.localizedDescription + details:error.domain]; +} + +- (void)sendErrorWithCode:(NSString*)code + message:(NSString* _Nullable)message + details:(id _Nullable)details { + FlutterError* flutterError = [FlutterError errorWithCode:code message:message details:details]; + [self send:flutterError]; +} + +- (void)sendNotImplemented { + [self send:FlutterMethodNotImplemented]; +} + +/** + * Sends result to flutterResult on the main thread. + */ +- (void)send:(id _Nullable)result { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + self->_flutterResult(result); + }); + } else { + _flutterResult(result); + } +} + +@end diff --git a/packages/camera/camera/ios/Classes/camera-umbrella.h b/packages/camera/camera/ios/Classes/camera-umbrella.h index 5c39401e6261..b0fd493b24df 100644 --- a/packages/camera/camera/ios/Classes/camera-umbrella.h +++ b/packages/camera/camera/ios/Classes/camera-umbrella.h @@ -4,6 +4,7 @@ #import #import +#import FOUNDATION_EXPORT double cameraVersionNumber; FOUNDATION_EXPORT const unsigned char cameraVersionString[]; diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index f68d026b206c..6b4db7cd91ec 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.4+2 +version: 0.9.4+3 environment: sdk: ">=2.14.0 <3.0.0" From cdbd62b4185ed922ef2ffa846db5ee9057b16202 Mon Sep 17 00:00:00 2001 From: Balvinder Singh Gambhir Date: Thu, 21 Oct 2021 02:43:03 +0530 Subject: [PATCH 358/364] [flutter_plugin_android_lifecycle] remove placeholder dart file (#4413) --- packages/flutter_plugin_android_lifecycle/CHANGELOG.md | 3 ++- .../lib/flutter_plugin_android_lifecycle.dart | 6 ------ packages/flutter_plugin_android_lifecycle/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) delete mode 100644 packages/flutter_plugin_android_lifecycle/lib/flutter_plugin_android_lifecycle.dart diff --git a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md index 7e567d8cce5c..27cca016960e 100644 --- a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md +++ b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 2.0.4 * Updated Android lint settings. +* Remove placeholder Dart file. ## 2.0.3 diff --git a/packages/flutter_plugin_android_lifecycle/lib/flutter_plugin_android_lifecycle.dart b/packages/flutter_plugin_android_lifecycle/lib/flutter_plugin_android_lifecycle.dart deleted file mode 100644 index 340b06832f19..000000000000 --- a/packages/flutter_plugin_android_lifecycle/lib/flutter_plugin_android_lifecycle.dart +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2013 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. - -// The flutter_plugin_android_lifecycle plugin only provides a Java API -// for use by Android plugins. This plugin has no Dart code. diff --git a/packages/flutter_plugin_android_lifecycle/pubspec.yaml b/packages/flutter_plugin_android_lifecycle/pubspec.yaml index 0fc128d03e17..8535de17a51c 100644 --- a/packages/flutter_plugin_android_lifecycle/pubspec.yaml +++ b/packages/flutter_plugin_android_lifecycle/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_plugin_android_lifecycle description: Flutter plugin for accessing an Android Lifecycle within other plugins. repository: https://github.com/flutter/plugins/tree/master/packages/flutter_plugin_android_lifecycle issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_plugin_android_lifecycle%22 -version: 2.0.3 +version: 2.0.4 environment: sdk: ">=2.12.0 <3.0.0" From c85fa03771caa15e7eaee536189a0fe665d662d3 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Wed, 20 Oct 2021 19:53:05 -0400 Subject: [PATCH 359/364] [shared_preferences] Switch to new analysis options (#4384) --- .../shared_preferences/analysis_options.yaml | 1 - .../shared_preferences/CHANGELOG.md | 4 + .../shared_preferences_test.dart | 91 +++++++-------- .../shared_preferences/example/lib/main.dart | 12 +- .../lib/shared_preferences.dart | 5 +- .../test/shared_preferences_test.dart | 107 ++++++++++-------- .../shared_preferences_linux/CHANGELOG.md | 4 + .../shared_preferences_test.dart | 47 ++++---- .../example/lib/main.dart | 12 +- .../lib/shared_preferences_linux.dart | 37 +++--- .../shared_preferences_linux/pubspec.yaml | 2 +- .../test/shared_preferences_linux_test.dart | 18 +-- .../shared_preferences_macos/CHANGELOG.md | 4 + .../shared_preferences_test.dart | 37 +++--- .../example/lib/main.dart | 8 +- .../example/pubspec.yaml | 2 +- .../CHANGELOG.md | 4 + .../method_channel_shared_preferences.dart | 4 +- ...ethod_channel_shared_preferences_test.dart | 14 +-- .../shared_preferences_web/CHANGELOG.md | 4 + .../shared_preferences_web_test.dart | 4 +- .../example/lib/main.dart | 2 +- .../example/pubspec.yaml | 10 +- .../lib/shared_preferences_web.dart | 14 +-- .../shared_preferences_windows/CHANGELOG.md | 4 + .../shared_preferences_test.dart | 38 ++++--- .../example/lib/main.dart | 12 +- .../lib/shared_preferences_windows.dart | 32 +++--- .../shared_preferences_windows/pubspec.yaml | 2 +- .../test/shared_preferences_windows_test.dart | 17 +-- script/configs/custom_analysis.yaml | 1 - 31 files changed, 296 insertions(+), 257 deletions(-) delete mode 100644 packages/shared_preferences/analysis_options.yaml diff --git a/packages/shared_preferences/analysis_options.yaml b/packages/shared_preferences/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/shared_preferences/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/packages/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index db12fd1829aa..5b9179664385 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fixes newly enabled analyzer options. + ## 2.0.8 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart b/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart index e8498f473a2c..fafdc18ec8b1 100644 --- a/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences/example/integration_test/shared_preferences_test.dart @@ -4,30 +4,27 @@ import 'dart:async'; import 'dart:io'; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('$SharedPreferences', () { - const Map kTestValues = { - 'flutter.String': 'hello world', - 'flutter.bool': true, - 'flutter.int': 42, - 'flutter.double': 3.14159, - 'flutter.List': ['foo', 'bar'], - }; + const String testString = 'hello world'; + const bool testBool = true; + const int testInt = 42; + const double testDouble = 3.14159; + const List testList = ['foo', 'bar']; - const Map kTestValues2 = { - 'flutter.String': 'goodbye world', - 'flutter.bool': false, - 'flutter.int': 1337, - 'flutter.double': 2.71828, - 'flutter.List': ['baz', 'quox'], - }; + const String testString2 = 'goodbye world'; + const bool testBool2 = false; + const int testInt2 = 1337; + const double testDouble2 = 2.71828; + const List testList2 = ['baz', 'quox']; late SharedPreferences preferences; @@ -54,36 +51,36 @@ void main() { testWidgets('writing', (WidgetTester _) async { await Future.wait(>[ - preferences.setString('String', kTestValues2['flutter.String']), - preferences.setBool('bool', kTestValues2['flutter.bool']), - preferences.setInt('int', kTestValues2['flutter.int']), - preferences.setDouble('double', kTestValues2['flutter.double']), - preferences.setStringList('List', kTestValues2['flutter.List']) + preferences.setString('String', testString2), + preferences.setBool('bool', testBool2), + preferences.setInt('int', testInt2), + preferences.setDouble('double', testDouble2), + preferences.setStringList('List', testList2) ]); - expect(preferences.getString('String'), kTestValues2['flutter.String']); - expect(preferences.getBool('bool'), kTestValues2['flutter.bool']); - expect(preferences.getInt('int'), kTestValues2['flutter.int']); - expect(preferences.getDouble('double'), kTestValues2['flutter.double']); - expect(preferences.getStringList('List'), kTestValues2['flutter.List']); + expect(preferences.getString('String'), testString2); + expect(preferences.getBool('bool'), testBool2); + expect(preferences.getInt('int'), testInt2); + expect(preferences.getDouble('double'), testDouble2); + expect(preferences.getStringList('List'), testList2); }); testWidgets('removing', (WidgetTester _) async { const String key = 'testKey'; - await preferences.setString(key, kTestValues['flutter.String']); - await preferences.setBool(key, kTestValues['flutter.bool']); - await preferences.setInt(key, kTestValues['flutter.int']); - await preferences.setDouble(key, kTestValues['flutter.double']); - await preferences.setStringList(key, kTestValues['flutter.List']); + await preferences.setString(key, testString); + await preferences.setBool(key, testBool); + await preferences.setInt(key, testInt); + await preferences.setDouble(key, testDouble); + await preferences.setStringList(key, testList); await preferences.remove(key); expect(preferences.get('testKey'), isNull); }); testWidgets('clearing', (WidgetTester _) async { - await preferences.setString('String', kTestValues['flutter.String']); - await preferences.setBool('bool', kTestValues['flutter.bool']); - await preferences.setInt('int', kTestValues['flutter.int']); - await preferences.setDouble('double', kTestValues['flutter.double']); - await preferences.setStringList('List', kTestValues['flutter.List']); + await preferences.setString('String', testString); + await preferences.setBool('bool', testBool); + await preferences.setInt('int', testInt); + await preferences.setDouble('double', testDouble); + await preferences.setStringList('List', testList); await preferences.clear(); expect(preferences.getString('String'), null); expect(preferences.getBool('bool'), null); @@ -94,13 +91,13 @@ void main() { testWidgets('simultaneous writes', (WidgetTester _) async { final List> writes = >[]; - final int writeCount = 100; + const int writeCount = 100; for (int i = 1; i <= writeCount; i++) { writes.add(preferences.setInt('int', i)); } - List result = await Future.wait(writes, eagerError: true); + final List result = await Future.wait(writes, eagerError: true); // All writes should succeed. - expect(result.where((element) => !element), isEmpty); + expect(result.where((bool element) => !element), isEmpty); // The last write should win. expect(preferences.getInt('int'), writeCount); }); @@ -112,28 +109,22 @@ void main() { // special prefixes plus a string value expect( // prefix for lists - preferences.setString( - 'String', - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' + - kTestValues2['flutter.String']), + preferences.setString('String', + 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu' + testString), throwsA(isA())); await preferences.reload(); expect(preferences.getString('String'), null); expect( // prefix for big integers - preferences.setString( - 'String', - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' + - kTestValues2['flutter.String']), + preferences.setString('String', + 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBCaWdJbnRlZ2Vy' + testString), throwsA(isA())); await preferences.reload(); expect(preferences.getString('String'), null); expect( // prefix for doubles - preferences.setString( - 'String', - 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' + - kTestValues2['flutter.String']), + preferences.setString('String', + 'VGhpcyBpcyB0aGUgcHJlZml4IGZvciBEb3VibGUu' + testString), throwsA(isA())); await preferences.reload(); expect(preferences.getString('String'), null); diff --git a/packages/shared_preferences/shared_preferences/example/lib/main.dart b/packages/shared_preferences/shared_preferences/example/lib/main.dart index 103481a2d295..43f3c78ad920 100644 --- a/packages/shared_preferences/shared_preferences/example/lib/main.dart +++ b/packages/shared_preferences/shared_preferences/example/lib/main.dart @@ -16,7 +16,7 @@ void main() { class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return const MaterialApp( title: 'SharedPreferences Demo', home: SharedPreferencesDemo(), ); @@ -24,14 +24,14 @@ class MyApp extends StatelessWidget { } class SharedPreferencesDemo extends StatefulWidget { - SharedPreferencesDemo({Key? key}) : super(key: key); + const SharedPreferencesDemo({Key? key}) : super(key: key); @override SharedPreferencesDemoState createState() => SharedPreferencesDemoState(); } class SharedPreferencesDemoState extends State { - Future _prefs = SharedPreferences.getInstance(); + final Future _prefs = SharedPreferences.getInstance(); late Future _counter; Future _incrementCounter() async { @@ -39,7 +39,7 @@ class SharedPreferencesDemoState extends State { final int counter = (prefs.getInt('counter') ?? 0) + 1; setState(() { - _counter = prefs.setInt("counter", counter).then((bool success) { + _counter = prefs.setInt('counter', counter).then((bool success) { return counter; }); }); @@ -49,7 +49,7 @@ class SharedPreferencesDemoState extends State { void initState() { super.initState(); _counter = _prefs.then((SharedPreferences prefs) { - return (prefs.getInt('counter') ?? 0); + return prefs.getInt('counter') ?? 0; }); } @@ -57,7 +57,7 @@ class SharedPreferencesDemoState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("SharedPreferences Demo"), + title: const Text('SharedPreferences Demo'), ), body: Center( child: FutureBuilder( diff --git a/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart b/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart index 841d615262de..b0a11fc8b13a 100644 --- a/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart +++ b/packages/shared_preferences/shared_preferences/lib/shared_preferences.dart @@ -50,7 +50,8 @@ class SharedPreferences { /// performance-sensitive blocks. static Future getInstance() async { if (_completer == null) { - final completer = Completer(); + final Completer completer = + Completer(); try { final Map preferencesMap = await _getSharedPreferencesMap(); @@ -188,7 +189,7 @@ class SharedPreferences { assert(fromSystem != null); // Strip the flutter. prefix from the returned preferences. final Map preferencesMap = {}; - for (String key in fromSystem.keys) { + for (final String key in fromSystem.keys) { assert(key.startsWith(_prefix)); preferencesMap[key.substring(_prefix.length)] = fromSystem[key]!; } diff --git a/packages/shared_preferences/shared_preferences/test/shared_preferences_test.dart b/packages/shared_preferences/shared_preferences/test/shared_preferences_test.dart index 878021a03361..1938a04ae84b 100755 --- a/packages/shared_preferences/shared_preferences/test/shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences/test/shared_preferences_test.dart @@ -11,27 +11,37 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('SharedPreferences', () { - const Map kTestValues = { - 'flutter.String': 'hello world', - 'flutter.bool': true, - 'flutter.int': 42, - 'flutter.double': 3.14159, - 'flutter.List': ['foo', 'bar'], + const String testString = 'hello world'; + const bool testBool = true; + const int testInt = 42; + const double testDouble = 3.14159; + const List testList = ['foo', 'bar']; + const Map testValues = { + 'flutter.String': testString, + 'flutter.bool': testBool, + 'flutter.int': testInt, + 'flutter.double': testDouble, + 'flutter.List': testList, }; - const Map kTestValues2 = { - 'flutter.String': 'goodbye world', - 'flutter.bool': false, - 'flutter.int': 1337, - 'flutter.double': 2.71828, - 'flutter.List': ['baz', 'quox'], + const String testString2 = 'goodbye world'; + const bool testBool2 = false; + const int testInt2 = 1337; + const double testDouble2 = 2.71828; + const List testList2 = ['baz', 'quox']; + const Map testValues2 = { + 'flutter.String': testString2, + 'flutter.bool': testBool2, + 'flutter.int': testInt2, + 'flutter.double': testDouble2, + 'flutter.List': testList2, }; late FakeSharedPreferencesStore store; late SharedPreferences preferences; setUp(() async { - store = FakeSharedPreferencesStore(kTestValues); + store = FakeSharedPreferencesStore(testValues); SharedPreferencesStorePlatform.instance = store; preferences = await SharedPreferences.getInstance(); store.log.clear(); @@ -43,26 +53,26 @@ void main() { }); test('reading', () async { - expect(preferences.get('String'), kTestValues['flutter.String']); - expect(preferences.get('bool'), kTestValues['flutter.bool']); - expect(preferences.get('int'), kTestValues['flutter.int']); - expect(preferences.get('double'), kTestValues['flutter.double']); - expect(preferences.get('List'), kTestValues['flutter.List']); - expect(preferences.getString('String'), kTestValues['flutter.String']); - expect(preferences.getBool('bool'), kTestValues['flutter.bool']); - expect(preferences.getInt('int'), kTestValues['flutter.int']); - expect(preferences.getDouble('double'), kTestValues['flutter.double']); - expect(preferences.getStringList('List'), kTestValues['flutter.List']); + expect(preferences.get('String'), testString); + expect(preferences.get('bool'), testBool); + expect(preferences.get('int'), testInt); + expect(preferences.get('double'), testDouble); + expect(preferences.get('List'), testList); + expect(preferences.getString('String'), testString); + expect(preferences.getBool('bool'), testBool); + expect(preferences.getInt('int'), testInt); + expect(preferences.getDouble('double'), testDouble); + expect(preferences.getStringList('List'), testList); expect(store.log, []); }); test('writing', () async { await Future.wait(>[ - preferences.setString('String', kTestValues2['flutter.String']), - preferences.setBool('bool', kTestValues2['flutter.bool']), - preferences.setInt('int', kTestValues2['flutter.int']), - preferences.setDouble('double', kTestValues2['flutter.double']), - preferences.setStringList('List', kTestValues2['flutter.List']) + preferences.setString('String', testString2), + preferences.setBool('bool', testBool2), + preferences.setInt('int', testInt2), + preferences.setDouble('double', testDouble2), + preferences.setStringList('List', testList2) ]); expect( store.log, @@ -70,37 +80,37 @@ void main() { isMethodCall('setValue', arguments: [ 'String', 'flutter.String', - kTestValues2['flutter.String'], + testString2, ]), isMethodCall('setValue', arguments: [ 'Bool', 'flutter.bool', - kTestValues2['flutter.bool'], + testBool2, ]), isMethodCall('setValue', arguments: [ 'Int', 'flutter.int', - kTestValues2['flutter.int'], + testInt2, ]), isMethodCall('setValue', arguments: [ 'Double', 'flutter.double', - kTestValues2['flutter.double'], + testDouble2, ]), isMethodCall('setValue', arguments: [ 'StringList', 'flutter.List', - kTestValues2['flutter.List'], + testList2, ]), ], ); store.log.clear(); - expect(preferences.getString('String'), kTestValues2['flutter.String']); - expect(preferences.getBool('bool'), kTestValues2['flutter.bool']); - expect(preferences.getInt('int'), kTestValues2['flutter.int']); - expect(preferences.getDouble('double'), kTestValues2['flutter.double']); - expect(preferences.getStringList('List'), kTestValues2['flutter.List']); + expect(preferences.getString('String'), testString2); + expect(preferences.getBool('bool'), testBool2); + expect(preferences.getInt('int'), testInt2); + expect(preferences.getDouble('double'), testDouble2); + expect(preferences.getStringList('List'), testList2); expect(store.log, equals([])); }); @@ -139,16 +149,15 @@ void main() { }); test('reloading', () async { - await preferences.setString( - 'String', kTestValues['flutter.String'] as String); - expect(preferences.getString('String'), kTestValues['flutter.String']); + await preferences.setString('String', testString); + expect(preferences.getString('String'), testString); SharedPreferences.setMockInitialValues( - kTestValues2.cast()); - expect(preferences.getString('String'), kTestValues['flutter.String']); + testValues2.cast()); + expect(preferences.getString('String'), testString); await preferences.reload(); - expect(preferences.getString('String'), kTestValues2['flutter.String']); + expect(preferences.getString('String'), testString2); }); test('back to back calls should return same instance.', () async { @@ -189,13 +198,13 @@ void main() { test('writing copy of strings list', () async { final List myList = []; - await preferences.setStringList("myList", myList); - myList.add("foobar"); + await preferences.setStringList('myList', myList); + myList.add('foobar'); final List cachedList = preferences.getStringList('myList')!; expect(cachedList, []); - cachedList.add("foobar2"); + cachedList.add('foobar2'); expect(preferences.getStringList('myList'), []); }); @@ -223,13 +232,13 @@ class FakeSharedPreferencesStore implements SharedPreferencesStorePlatform { @override Future clear() { - log.add(MethodCall('clear')); + log.add(const MethodCall('clear')); return backend.clear(); } @override Future> getAll() { - log.add(MethodCall('getAll')); + log.add(const MethodCall('getAll')); return backend.getAll(); } diff --git a/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md b/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md index fc09bec23591..6fd14eebc7f0 100644 --- a/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fixes newly enabled analyzer options. + ## 2.0.2 * Updated installation instructions in README. diff --git a/packages/shared_preferences/shared_preferences_linux/example/integration_test/shared_preferences_test.dart b/packages/shared_preferences/shared_preferences_linux/example/integration_test/shared_preferences_test.dart index 5dba3def31d0..b33d6011f5c4 100644 --- a/packages/shared_preferences/shared_preferences_linux/example/integration_test/shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences_linux/example/integration_test/shared_preferences_test.dart @@ -12,7 +12,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('SharedPreferencesLinux', () { - const Map kTestValues = { + const Map kTestValues = { 'flutter.String': 'hello world', 'flutter.bool': true, 'flutter.int': 42, @@ -20,7 +20,7 @@ void main() { 'flutter.List': ['foo', 'bar'], }; - const Map kTestValues2 = { + const Map kTestValues2 = { 'flutter.String': 'goodbye world', 'flutter.bool': false, 'flutter.int': 1337, @@ -39,7 +39,7 @@ void main() { }); testWidgets('reading', (WidgetTester _) async { - final all = await preferences.getAll(); + final Map all = await preferences.getAll(); expect(all['String'], isNull); expect(all['bool'], isNull); expect(all['int'], isNull); @@ -50,14 +50,15 @@ void main() { testWidgets('writing', (WidgetTester _) async { await Future.wait(>[ preferences.setValue( - 'String', 'String', kTestValues2['flutter.String']), - preferences.setValue('Bool', 'bool', kTestValues2['flutter.bool']), - preferences.setValue('Int', 'int', kTestValues2['flutter.int']), + 'String', 'String', kTestValues2['flutter.String']!), + preferences.setValue('Bool', 'bool', kTestValues2['flutter.bool']!), + preferences.setValue('Int', 'int', kTestValues2['flutter.int']!), preferences.setValue( - 'Double', 'double', kTestValues2['flutter.double']), - preferences.setValue('StringList', 'List', kTestValues2['flutter.List']) + 'Double', 'double', kTestValues2['flutter.double']!), + preferences.setValue( + 'StringList', 'List', kTestValues2['flutter.List']!) ]); - final all = await preferences.getAll(); + final Map all = await preferences.getAll(); expect(all['String'], kTestValues2['flutter.String']); expect(all['bool'], kTestValues2['flutter.bool']); expect(all['int'], kTestValues2['flutter.int']); @@ -68,28 +69,30 @@ void main() { testWidgets('removing', (WidgetTester _) async { const String key = 'testKey'; - await Future.wait([ - preferences.setValue('String', key, kTestValues['flutter.String']), - preferences.setValue('Bool', key, kTestValues['flutter.bool']), - preferences.setValue('Int', key, kTestValues['flutter.int']), - preferences.setValue('Double', key, kTestValues['flutter.double']), - preferences.setValue('StringList', key, kTestValues['flutter.List']) + await Future.wait(>[ + preferences.setValue('String', key, kTestValues['flutter.String']!), + preferences.setValue('Bool', key, kTestValues['flutter.bool']!), + preferences.setValue('Int', key, kTestValues['flutter.int']!), + preferences.setValue('Double', key, kTestValues['flutter.double']!), + preferences.setValue('StringList', key, kTestValues['flutter.List']!) ]); await preferences.remove(key); - final all = await preferences.getAll(); + final Map all = await preferences.getAll(); expect(all['testKey'], isNull); }); testWidgets('clearing', (WidgetTester _) async { await Future.wait(>[ - preferences.setValue('String', 'String', kTestValues['flutter.String']), - preferences.setValue('Bool', 'bool', kTestValues['flutter.bool']), - preferences.setValue('Int', 'int', kTestValues['flutter.int']), - preferences.setValue('Double', 'double', kTestValues['flutter.double']), - preferences.setValue('StringList', 'List', kTestValues['flutter.List']) + preferences.setValue( + 'String', 'String', kTestValues['flutter.String']!), + preferences.setValue('Bool', 'bool', kTestValues['flutter.bool']!), + preferences.setValue('Int', 'int', kTestValues['flutter.int']!), + preferences.setValue( + 'Double', 'double', kTestValues['flutter.double']!), + preferences.setValue('StringList', 'List', kTestValues['flutter.List']!) ]); await preferences.clear(); - final all = await preferences.getAll(); + final Map all = await preferences.getAll(); expect(all['String'], null); expect(all['bool'], null); expect(all['int'], null); diff --git a/packages/shared_preferences/shared_preferences_linux/example/lib/main.dart b/packages/shared_preferences/shared_preferences_linux/example/lib/main.dart index aee71d00d44d..b4a7ea3f2c1e 100644 --- a/packages/shared_preferences/shared_preferences_linux/example/lib/main.dart +++ b/packages/shared_preferences/shared_preferences_linux/example/lib/main.dart @@ -16,7 +16,7 @@ void main() { class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return const MaterialApp( title: 'SharedPreferences Demo', home: SharedPreferencesDemo(), ); @@ -24,18 +24,18 @@ class MyApp extends StatelessWidget { } class SharedPreferencesDemo extends StatefulWidget { - SharedPreferencesDemo({Key? key}) : super(key: key); + const SharedPreferencesDemo({Key? key}) : super(key: key); @override SharedPreferencesDemoState createState() => SharedPreferencesDemoState(); } class SharedPreferencesDemoState extends State { - final prefs = SharedPreferencesLinux.instance; + final SharedPreferencesLinux prefs = SharedPreferencesLinux.instance; late Future _counter; Future _incrementCounter() async { - final values = await prefs.getAll(); + final Map values = await prefs.getAll(); final int counter = (values['counter'] as int? ?? 0) + 1; setState(() { @@ -49,7 +49,7 @@ class SharedPreferencesDemoState extends State { void initState() { super.initState(); _counter = prefs.getAll().then((Map values) { - return (values['counter'] as int? ?? 0); + return values['counter'] as int? ?? 0; }); } @@ -57,7 +57,7 @@ class SharedPreferencesDemoState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("SharedPreferences Demo"), + title: const Text('SharedPreferences Demo'), ), body: Center( child: FutureBuilder( diff --git a/packages/shared_preferences/shared_preferences_linux/lib/shared_preferences_linux.dart b/packages/shared_preferences/shared_preferences_linux/lib/shared_preferences_linux.dart index 5ec988216074..cf887ccb7ad1 100644 --- a/packages/shared_preferences/shared_preferences_linux/lib/shared_preferences_linux.dart +++ b/packages/shared_preferences/shared_preferences_linux/lib/shared_preferences_linux.dart @@ -17,8 +17,8 @@ import 'package:shared_preferences_platform_interface/shared_preferences_platfor /// This class implements the `package:shared_preferences` functionality for Linux. class SharedPreferencesLinux extends SharedPreferencesStorePlatform { /// The default instance of [SharedPreferencesLinux] to use. - /// TODO(egarciad): Remove when the Dart plugin registrant lands on Flutter stable. - /// https://github.com/flutter/flutter/issues/81421 + // TODO(egarciad): Remove when the Dart plugin registrant lands on Flutter stable. + // https://github.com/flutter/flutter/issues/81421 static SharedPreferencesLinux instance = SharedPreferencesLinux(); /// Registers the Linux implementation. @@ -31,13 +31,15 @@ class SharedPreferencesLinux extends SharedPreferencesStorePlatform { /// File system used to store to disk. Exposed for testing only. @visibleForTesting - FileSystem fs = LocalFileSystem(); + FileSystem fs = const LocalFileSystem(); /// Gets the file where the preferences are stored. Future _getLocalDataFile() async { - final pathProvider = PathProviderLinux(); - final directory = await pathProvider.getApplicationSupportPath(); - if (directory == null) return null; + final PathProviderLinux pathProvider = PathProviderLinux(); + final String? directory = await pathProvider.getApplicationSupportPath(); + if (directory == null) { + return null; + } return fs.file(path.join(directory, 'shared_preferences.json')); } @@ -48,12 +50,15 @@ class SharedPreferencesLinux extends SharedPreferencesStorePlatform { return _cachedPreferences!; } - Map preferences = {}; + Map preferences = {}; final File? localDataFile = await _getLocalDataFile(); if (localDataFile != null && localDataFile.existsSync()) { - String stringMap = localDataFile.readAsStringSync(); + final String stringMap = localDataFile.readAsStringSync(); if (stringMap.isNotEmpty) { - preferences = json.decode(stringMap).cast(); + final Object? data = json.decode(stringMap); + if (data is Map) { + preferences = data.cast(); + } } } _cachedPreferences = preferences; @@ -64,18 +69,18 @@ class SharedPreferencesLinux extends SharedPreferencesStorePlatform { /// succeeded. Future _writePreferences(Map preferences) async { try { - var localDataFile = await _getLocalDataFile(); + final File? localDataFile = await _getLocalDataFile(); if (localDataFile == null) { - print("Unable to determine where to write preferences."); + print('Unable to determine where to write preferences.'); return false; } if (!localDataFile.existsSync()) { localDataFile.createSync(recursive: true); } - var stringMap = json.encode(preferences); + final String stringMap = json.encode(preferences); localDataFile.writeAsStringSync(stringMap); } catch (e) { - print("Error saving preferences to disk: $e"); + print('Error saving preferences to disk: $e'); return false; } return true; @@ -83,7 +88,7 @@ class SharedPreferencesLinux extends SharedPreferencesStorePlatform { @override Future clear() async { - var preferences = await _readPreferences(); + final Map preferences = await _readPreferences(); preferences.clear(); return _writePreferences(preferences); } @@ -95,14 +100,14 @@ class SharedPreferencesLinux extends SharedPreferencesStorePlatform { @override Future remove(String key) async { - var preferences = await _readPreferences(); + final Map preferences = await _readPreferences(); preferences.remove(key); return _writePreferences(preferences); } @override Future setValue(String valueType, String key, Object value) async { - var preferences = await _readPreferences(); + final Map preferences = await _readPreferences(); preferences[key] = value; return _writePreferences(preferences); } diff --git a/packages/shared_preferences/shared_preferences_linux/pubspec.yaml b/packages/shared_preferences/shared_preferences_linux/pubspec.yaml index c03e49e042e2..b6d1765c08f3 100644 --- a/packages/shared_preferences/shared_preferences_linux/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_linux/pubspec.yaml @@ -18,9 +18,9 @@ flutter: dependencies: file: ^6.0.0 - meta: ^1.3.0 flutter: sdk: flutter + meta: ^1.3.0 path: ^1.8.0 path_provider_linux: ^2.0.0 shared_preferences_platform_interface: ^2.0.0 diff --git a/packages/shared_preferences/shared_preferences_linux/test/shared_preferences_linux_test.dart b/packages/shared_preferences/shared_preferences_linux/test/shared_preferences_linux_test.dart index 62ec2b66c07a..40dbdd58afc1 100644 --- a/packages/shared_preferences/shared_preferences_linux/test/shared_preferences_linux_test.dart +++ b/packages/shared_preferences/shared_preferences_linux/test/shared_preferences_linux_test.dart @@ -18,12 +18,12 @@ void main() { }); Future _getFilePath() async { - final pathProvider = PathProviderLinux(); - final directory = await pathProvider.getApplicationSupportPath(); + final PathProviderLinux pathProvider = PathProviderLinux(); + final String? directory = await pathProvider.getApplicationSupportPath(); return path.join(directory!, 'shared_preferences.json'); } - _writeTestFile(String value) async { + Future _writeTestFile(String value) async { fs.file(await _getFilePath()) ..createSync(recursive: true) ..writeAsStringSync(value); @@ -34,7 +34,7 @@ void main() { } SharedPreferencesLinux _getPreferences() { - var prefs = SharedPreferencesLinux(); + final SharedPreferencesLinux prefs = SharedPreferencesLinux(); prefs.fs = fs; return prefs; } @@ -46,9 +46,9 @@ void main() { test('getAll', () async { await _writeTestFile('{"key1": "one", "key2": 2}'); - var prefs = _getPreferences(); + final SharedPreferencesLinux prefs = _getPreferences(); - var values = await prefs.getAll(); + final Map values = await prefs.getAll(); expect(values, hasLength(2)); expect(values['key1'], 'one'); expect(values['key2'], 2); @@ -56,7 +56,7 @@ void main() { test('remove', () async { await _writeTestFile('{"key1":"one","key2":2}'); - var prefs = _getPreferences(); + final SharedPreferencesLinux prefs = _getPreferences(); await prefs.remove('key2'); @@ -65,7 +65,7 @@ void main() { test('setValue', () async { await _writeTestFile('{}'); - var prefs = _getPreferences(); + final SharedPreferencesLinux prefs = _getPreferences(); await prefs.setValue('', 'key1', 'one'); await prefs.setValue('', 'key2', 2); @@ -75,7 +75,7 @@ void main() { test('clear', () async { await _writeTestFile('{"key1":"one","key2":2}'); - var prefs = _getPreferences(); + final SharedPreferencesLinux prefs = _getPreferences(); await prefs.clear(); expect(await _readTestFile(), '{}'); diff --git a/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md b/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md index 2f7e0edf9a51..7a2e3ab74e72 100644 --- a/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_macos/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fixes newly enabled analyzer options. + ## 2.0.2 * Add native unit tests. diff --git a/packages/shared_preferences/shared_preferences_macos/example/integration_test/shared_preferences_test.dart b/packages/shared_preferences/shared_preferences_macos/example/integration_test/shared_preferences_test.dart index e7a267f7e51a..66e3be30ee5d 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/integration_test/shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences_macos/example/integration_test/shared_preferences_test.dart @@ -3,15 +3,16 @@ // found in the LICENSE file. import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('SharedPreferencesMacOS', () { - const Map kTestValues = { + const Map kTestValues = { 'flutter.String': 'hello world', 'flutter.bool': true, 'flutter.int': 42, @@ -19,7 +20,7 @@ void main() { 'flutter.List': ['foo', 'bar'], }; - const Map kTestValues2 = { + const Map kTestValues2 = { 'flutter.String': 'goodbye world', 'flutter.bool': false, 'flutter.int': 1337, @@ -55,15 +56,15 @@ void main() { testWidgets('writing', (WidgetTester _) async { await Future.wait(>[ preferences.setValue( - 'String', _prefixedKey('String'), kTestValues2['flutter.String']), + 'String', _prefixedKey('String'), kTestValues2['flutter.String']!), preferences.setValue( - 'Bool', _prefixedKey('bool'), kTestValues2['flutter.bool']), + 'Bool', _prefixedKey('bool'), kTestValues2['flutter.bool']!), preferences.setValue( - 'Int', _prefixedKey('int'), kTestValues2['flutter.int']), + 'Int', _prefixedKey('int'), kTestValues2['flutter.int']!), preferences.setValue( - 'Double', _prefixedKey('double'), kTestValues2['flutter.double']), + 'Double', _prefixedKey('double'), kTestValues2['flutter.double']!), preferences.setValue( - 'StringList', _prefixedKey('List'), kTestValues2['flutter.List']) + 'StringList', _prefixedKey('List'), kTestValues2['flutter.List']!) ]); final Map values = await preferences.getAll(); expect(values[_prefixedKey('String')], kTestValues2['flutter.String']); @@ -75,12 +76,12 @@ void main() { testWidgets('removing', (WidgetTester _) async { final String key = _prefixedKey('testKey'); - await preferences.setValue('String', key, kTestValues['flutter.String']); - await preferences.setValue('Bool', key, kTestValues['flutter.bool']); - await preferences.setValue('Int', key, kTestValues['flutter.int']); - await preferences.setValue('Double', key, kTestValues['flutter.double']); + await preferences.setValue('String', key, kTestValues['flutter.String']!); + await preferences.setValue('Bool', key, kTestValues['flutter.bool']!); + await preferences.setValue('Int', key, kTestValues['flutter.int']!); + await preferences.setValue('Double', key, kTestValues['flutter.double']!); await preferences.setValue( - 'StringList', key, kTestValues['flutter.List']); + 'StringList', key, kTestValues['flutter.List']!); await preferences.remove(key); final Map values = await preferences.getAll(); expect(values[key], isNull); @@ -88,13 +89,13 @@ void main() { testWidgets('clearing', (WidgetTester _) async { await preferences.setValue( - 'String', 'String', kTestValues['flutter.String']); - await preferences.setValue('Bool', 'bool', kTestValues['flutter.bool']); - await preferences.setValue('Int', 'int', kTestValues['flutter.int']); + 'String', 'String', kTestValues['flutter.String']!); + await preferences.setValue('Bool', 'bool', kTestValues['flutter.bool']!); + await preferences.setValue('Int', 'int', kTestValues['flutter.int']!); await preferences.setValue( - 'Double', 'double', kTestValues['flutter.double']); + 'Double', 'double', kTestValues['flutter.double']!); await preferences.setValue( - 'StringList', 'List', kTestValues['flutter.List']); + 'StringList', 'List', kTestValues['flutter.List']!); await preferences.clear(); final Map values = await preferences.getAll(); expect(values['String'], null); diff --git a/packages/shared_preferences/shared_preferences_macos/example/lib/main.dart b/packages/shared_preferences/shared_preferences_macos/example/lib/main.dart index fb85c301f623..349e9c45405a 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/lib/main.dart +++ b/packages/shared_preferences/shared_preferences_macos/example/lib/main.dart @@ -16,7 +16,7 @@ void main() { class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return const MaterialApp( title: 'SharedPreferences Demo', home: SharedPreferencesDemo(), ); @@ -24,14 +24,14 @@ class MyApp extends StatelessWidget { } class SharedPreferencesDemo extends StatefulWidget { - SharedPreferencesDemo({Key? key}) : super(key: key); + const SharedPreferencesDemo({Key? key}) : super(key: key); @override SharedPreferencesDemoState createState() => SharedPreferencesDemoState(); } class SharedPreferencesDemoState extends State { - SharedPreferencesStorePlatform _prefs = + final SharedPreferencesStorePlatform _prefs = SharedPreferencesStorePlatform.instance; late Future _counter; @@ -63,7 +63,7 @@ class SharedPreferencesDemoState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("SharedPreferences Demo"), + title: const Text('SharedPreferences Demo'), ), body: Center( child: FutureBuilder( diff --git a/packages/shared_preferences/shared_preferences_macos/example/pubspec.yaml b/packages/shared_preferences/shared_preferences_macos/example/pubspec.yaml index e6bf972cf5b4..d6f07f8eb2af 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_macos/example/pubspec.yaml @@ -9,7 +9,6 @@ environment: dependencies: flutter: sdk: flutter - shared_preferences_platform_interface: ^2.0.0 shared_preferences_macos: # When depending on this package from a real application you should use: # shared_preferences_macos: ^x.y.z @@ -17,6 +16,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ + shared_preferences_platform_interface: ^2.0.0 dev_dependencies: flutter_driver: diff --git a/packages/shared_preferences/shared_preferences_platform_interface/CHANGELOG.md b/packages/shared_preferences/shared_preferences_platform_interface/CHANGELOG.md index b402f6e57e88..51b59651b49a 100644 --- a/packages/shared_preferences/shared_preferences_platform_interface/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fixes newly enabled analyzer options. + ## 2.0.0 * Migrate to null safety. diff --git a/packages/shared_preferences/shared_preferences_platform_interface/lib/method_channel_shared_preferences.dart b/packages/shared_preferences/shared_preferences_platform_interface/lib/method_channel_shared_preferences.dart index fa1bdc097b8d..2974f0a69e1b 100644 --- a/packages/shared_preferences/shared_preferences_platform_interface/lib/method_channel_shared_preferences.dart +++ b/packages/shared_preferences/shared_preferences_platform_interface/lib/method_channel_shared_preferences.dart @@ -43,7 +43,9 @@ class MethodChannelSharedPreferencesStore final Map? preferences = await _kChannel.invokeMapMethod('getAll'); - if (preferences == null) return {}; + if (preferences == null) { + return {}; + } return preferences; } } diff --git a/packages/shared_preferences/shared_preferences_platform_interface/test/method_channel_shared_preferences_test.dart b/packages/shared_preferences/shared_preferences_platform_interface/test/method_channel_shared_preferences_test.dart index 3b43062c2be3..31ee89e4c3f6 100644 --- a/packages/shared_preferences/shared_preferences_platform_interface/test/method_channel_shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences_platform_interface/test/method_channel_shared_preferences_test.dart @@ -4,8 +4,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:shared_preferences_platform_interface/method_channel_shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -37,7 +37,7 @@ void main() { return await testData.getAll(); } if (methodCall.method == 'remove') { - final String key = methodCall.arguments['key']; + final String key = (methodCall.arguments['key'] as String?)!; return await testData.remove(key); } if (methodCall.method == 'clear') { @@ -47,8 +47,8 @@ void main() { final Match? match = setterRegExp.matchAsPrefix(methodCall.method); if (match?.groupCount == 1) { final String valueType = match!.group(1)!; - final String key = methodCall.arguments['key']; - final Object value = methodCall.arguments['value']; + final String key = (methodCall.arguments['key'] as String?)!; + final Object value = (methodCall.arguments['value'] as Object?)!; return await testData.setValue(valueType, key, value); } fail('Unexpected method call: ${methodCall.method}'); @@ -78,15 +78,15 @@ void main() { }); expect(log, hasLength(4)); - for (MethodCall call in log) { + for (final MethodCall call in log) { expect(call.method, 'remove'); } }); test('setValue', () async { expect(await testData.getAll(), isEmpty); - for (String key in kTestValues.keys) { - final dynamic value = kTestValues[key]; + for (final String key in kTestValues.keys) { + final Object value = kTestValues[key]!; expect(await store.setValue(key.split('.').last, key, value), true); } expect(await testData.getAll(), kTestValues); diff --git a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md index dd68f5321541..6332d01aa743 100644 --- a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fixes newly enabled analyzer options. + ## 2.0.2 * Add `implements` to pubspec. diff --git a/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart b/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart index d95a0512615e..d3bfa49af8a0 100644 --- a/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart +++ b/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart @@ -62,12 +62,12 @@ void main() { testWidgets('setValue', (WidgetTester tester) async { final SharedPreferencesPlugin store = SharedPreferencesPlugin(); - for (String key in kTestValues.keys) { + for (final String key in kTestValues.keys) { final dynamic value = kTestValues[key]; expect(await store.setValue(key.split('.').last, key, value), true); } expect(html.window.localStorage.keys, hasLength(kTestValues.length)); - for (String key in html.window.localStorage.keys) { + for (final String key in html.window.localStorage.keys) { expect(html.window.localStorage[key], json.encode(kTestValues[key])); } diff --git a/packages/shared_preferences/shared_preferences_web/example/lib/main.dart b/packages/shared_preferences/shared_preferences_web/example/lib/main.dart index e1a38dcdcd46..341913a18490 100644 --- a/packages/shared_preferences/shared_preferences_web/example/lib/main.dart +++ b/packages/shared_preferences/shared_preferences_web/example/lib/main.dart @@ -17,7 +17,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { @override Widget build(BuildContext context) { - return Directionality( + return const Directionality( textDirection: TextDirection.ltr, child: Text('Testing... Look at the console output for results!'), ); diff --git a/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml b/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml index a83a71b40bf8..832ba912e5a8 100644 --- a/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml @@ -6,16 +6,16 @@ environment: flutter: ">=2.2.0" dependencies: - shared_preferences_web: - path: ../ flutter: sdk: flutter + shared_preferences_web: + path: ../ dev_dependencies: - js: ^0.6.3 - flutter_test: - sdk: flutter flutter_driver: sdk: flutter + flutter_test: + sdk: flutter integration_test: sdk: flutter + js: ^0.6.3 diff --git a/packages/shared_preferences/shared_preferences_web/lib/shared_preferences_web.dart b/packages/shared_preferences/shared_preferences_web/lib/shared_preferences_web.dart index 9cff1d448896..d9d623465188 100644 --- a/packages/shared_preferences/shared_preferences_web/lib/shared_preferences_web.dart +++ b/packages/shared_preferences/shared_preferences_web/lib/shared_preferences_web.dart @@ -23,16 +23,14 @@ class SharedPreferencesPlugin extends SharedPreferencesStorePlatform { // IMPORTANT: Do not use html.window.localStorage.clear() as that will // remove _all_ local data, not just the keys prefixed with // "flutter." - for (String key in _storedFlutterKeys) { - html.window.localStorage.remove(key); - } + _storedFlutterKeys.forEach(html.window.localStorage.remove); return true; } @override Future> getAll() async { - final Map allData = {}; - for (String key in _storedFlutterKeys) { + final Map allData = {}; + for (final String key in _storedFlutterKeys) { allData[key] = _decodeValue(html.window.localStorage[key]!); } return allData; @@ -64,7 +62,7 @@ class SharedPreferencesPlugin extends SharedPreferencesStorePlatform { Iterable get _storedFlutterKeys { return html.window.localStorage.keys - .where((key) => key.startsWith('flutter.')); + .where((String key) => key.startsWith('flutter.')); } String _encodeValue(Object? value) { @@ -72,7 +70,7 @@ class SharedPreferencesPlugin extends SharedPreferencesStorePlatform { } Object _decodeValue(String encodedValue) { - final Object decodedValue = json.decode(encodedValue); + final Object? decodedValue = json.decode(encodedValue); if (decodedValue is List) { // JSON does not preserve generics. The encode/decode roundtrip is @@ -81,6 +79,6 @@ class SharedPreferencesPlugin extends SharedPreferencesStorePlatform { return decodedValue.cast(); } - return decodedValue; + return decodedValue!; } } diff --git a/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md b/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md index 7502ec917d80..68358a3c354e 100644 --- a/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Fixes newly enabled analyzer options. + ## 2.0.2 * Updated installation instructions in README. diff --git a/packages/shared_preferences/shared_preferences_windows/example/integration_test/shared_preferences_test.dart b/packages/shared_preferences/shared_preferences_windows/example/integration_test/shared_preferences_test.dart index 207d712650a7..ac138666518a 100644 --- a/packages/shared_preferences/shared_preferences_windows/example/integration_test/shared_preferences_test.dart +++ b/packages/shared_preferences/shared_preferences_windows/example/integration_test/shared_preferences_test.dart @@ -3,15 +3,16 @@ // found in the LICENSE file. import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_windows/shared_preferences_windows.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences_windows/shared_preferences_windows.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('SharedPreferencesWindows', () { - const Map kTestValues = { + const Map kTestValues = { 'flutter.String': 'hello world', 'flutter.bool': true, 'flutter.int': 42, @@ -19,7 +20,7 @@ void main() { 'flutter.List': ['foo', 'bar'], }; - const Map kTestValues2 = { + const Map kTestValues2 = { 'flutter.String': 'goodbye world', 'flutter.bool': false, 'flutter.int': 1337, @@ -49,12 +50,13 @@ void main() { testWidgets('writing', (WidgetTester _) async { await Future.wait(>[ preferences.setValue( - 'String', 'String', kTestValues2['flutter.String']), - preferences.setValue('Bool', 'bool', kTestValues2['flutter.bool']), - preferences.setValue('Int', 'int', kTestValues2['flutter.int']), + 'String', 'String', kTestValues2['flutter.String']!), + preferences.setValue('Bool', 'bool', kTestValues2['flutter.bool']!), + preferences.setValue('Int', 'int', kTestValues2['flutter.int']!), + preferences.setValue( + 'Double', 'double', kTestValues2['flutter.double']!), preferences.setValue( - 'Double', 'double', kTestValues2['flutter.double']), - preferences.setValue('StringList', 'List', kTestValues2['flutter.List']) + 'StringList', 'List', kTestValues2['flutter.List']!) ]); final Map values = await preferences.getAll(); expect(values['String'], kTestValues2['flutter.String']); @@ -66,12 +68,12 @@ void main() { testWidgets('removing', (WidgetTester _) async { const String key = 'testKey'; - await preferences.setValue('String', key, kTestValues['flutter.String']); - await preferences.setValue('Bool', key, kTestValues['flutter.bool']); - await preferences.setValue('Int', key, kTestValues['flutter.int']); - await preferences.setValue('Double', key, kTestValues['flutter.double']); + await preferences.setValue('String', key, kTestValues['flutter.String']!); + await preferences.setValue('Bool', key, kTestValues['flutter.bool']!); + await preferences.setValue('Int', key, kTestValues['flutter.int']!); + await preferences.setValue('Double', key, kTestValues['flutter.double']!); await preferences.setValue( - 'StringList', key, kTestValues['flutter.List']); + 'StringList', key, kTestValues['flutter.List']!); await preferences.remove(key); final Map values = await preferences.getAll(); expect(values[key], isNull); @@ -79,13 +81,13 @@ void main() { testWidgets('clearing', (WidgetTester _) async { await preferences.setValue( - 'String', 'String', kTestValues['flutter.String']); - await preferences.setValue('Bool', 'bool', kTestValues['flutter.bool']); - await preferences.setValue('Int', 'int', kTestValues['flutter.int']); + 'String', 'String', kTestValues['flutter.String']!); + await preferences.setValue('Bool', 'bool', kTestValues['flutter.bool']!); + await preferences.setValue('Int', 'int', kTestValues['flutter.int']!); await preferences.setValue( - 'Double', 'double', kTestValues['flutter.double']); + 'Double', 'double', kTestValues['flutter.double']!); await preferences.setValue( - 'StringList', 'List', kTestValues['flutter.List']); + 'StringList', 'List', kTestValues['flutter.List']!); await preferences.clear(); final Map values = await preferences.getAll(); expect(values['String'], null); diff --git a/packages/shared_preferences/shared_preferences_windows/example/lib/main.dart b/packages/shared_preferences/shared_preferences_windows/example/lib/main.dart index 0cdd37394706..423a727042cc 100644 --- a/packages/shared_preferences/shared_preferences_windows/example/lib/main.dart +++ b/packages/shared_preferences/shared_preferences_windows/example/lib/main.dart @@ -16,7 +16,7 @@ void main() { class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return const MaterialApp( title: 'SharedPreferences Demo', home: SharedPreferencesDemo(), ); @@ -24,18 +24,18 @@ class MyApp extends StatelessWidget { } class SharedPreferencesDemo extends StatefulWidget { - SharedPreferencesDemo({Key? key}) : super(key: key); + const SharedPreferencesDemo({Key? key}) : super(key: key); @override SharedPreferencesDemoState createState() => SharedPreferencesDemoState(); } class SharedPreferencesDemoState extends State { - final prefs = SharedPreferencesWindows.instance; + final SharedPreferencesWindows prefs = SharedPreferencesWindows.instance; late Future _counter; Future _incrementCounter() async { - final values = await prefs.getAll(); + final Map values = await prefs.getAll(); final int counter = (values['counter'] as int? ?? 0) + 1; setState(() { @@ -49,7 +49,7 @@ class SharedPreferencesDemoState extends State { void initState() { super.initState(); _counter = prefs.getAll().then((Map values) { - return (values['counter'] as int? ?? 0); + return values['counter'] as int? ?? 0; }); } @@ -57,7 +57,7 @@ class SharedPreferencesDemoState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("SharedPreferences Demo"), + title: const Text('SharedPreferences Demo'), ), body: Center( child: FutureBuilder( diff --git a/packages/shared_preferences/shared_preferences_windows/lib/shared_preferences_windows.dart b/packages/shared_preferences/shared_preferences_windows/lib/shared_preferences_windows.dart index b8cd3702b837..02f9f85d6381 100644 --- a/packages/shared_preferences/shared_preferences_windows/lib/shared_preferences_windows.dart +++ b/packages/shared_preferences/shared_preferences_windows/lib/shared_preferences_windows.dart @@ -4,20 +4,21 @@ import 'dart:async'; import 'dart:convert' show json; + import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; -import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:path_provider_windows/path_provider_windows.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; /// The Windows implementation of [SharedPreferencesStorePlatform]. /// /// This class implements the `package:shared_preferences` functionality for Windows. class SharedPreferencesWindows extends SharedPreferencesStorePlatform { /// The default instance of [SharedPreferencesWindows] to use. - /// TODO(egarciad): Remove when the Dart plugin registrant lands on Flutter stable. - /// https://github.com/flutter/flutter/issues/81421 + // TODO(egarciad): Remove when the Dart plugin registrant lands on Flutter stable. + // https://github.com/flutter/flutter/issues/81421 static SharedPreferencesWindows instance = SharedPreferencesWindows(); /// Registers the Windows implementation. @@ -27,7 +28,7 @@ class SharedPreferencesWindows extends SharedPreferencesStorePlatform { /// File system used to store to disk. Exposed for testing only. @visibleForTesting - FileSystem fs = LocalFileSystem(); + FileSystem fs = const LocalFileSystem(); /// The path_provider_windows instance used to find the support directory. @visibleForTesting @@ -44,7 +45,7 @@ class SharedPreferencesWindows extends SharedPreferencesStorePlatform { if (_localDataFilePath != null) { return _localDataFilePath!; } - final directory = await pathProvider.getApplicationSupportPath(); + final String? directory = await pathProvider.getApplicationSupportPath(); if (directory == null) { return null; } @@ -58,12 +59,15 @@ class SharedPreferencesWindows extends SharedPreferencesStorePlatform { if (_cachedPreferences != null) { return _cachedPreferences!; } - Map preferences = {}; + Map preferences = {}; final File? localDataFile = await _getLocalDataFile(); if (localDataFile != null && localDataFile.existsSync()) { - String stringMap = localDataFile.readAsStringSync(); + final String stringMap = localDataFile.readAsStringSync(); if (stringMap.isNotEmpty) { - preferences = json.decode(stringMap).cast(); + final Object? data = json.decode(stringMap); + if (data is Map) { + preferences = data.cast(); + } } } _cachedPreferences = preferences; @@ -76,16 +80,16 @@ class SharedPreferencesWindows extends SharedPreferencesStorePlatform { try { final File? localDataFile = await _getLocalDataFile(); if (localDataFile == null) { - print("Unable to determine where to write preferences."); + print('Unable to determine where to write preferences.'); return false; } if (!localDataFile.existsSync()) { localDataFile.createSync(recursive: true); } - String stringMap = json.encode(preferences); + final String stringMap = json.encode(preferences); localDataFile.writeAsStringSync(stringMap); } catch (e) { - print("Error saving preferences to disk: $e"); + print('Error saving preferences to disk: $e'); return false; } return true; @@ -93,7 +97,7 @@ class SharedPreferencesWindows extends SharedPreferencesStorePlatform { @override Future clear() async { - var preferences = await _readPreferences(); + final Map preferences = await _readPreferences(); preferences.clear(); return _writePreferences(preferences); } @@ -105,14 +109,14 @@ class SharedPreferencesWindows extends SharedPreferencesStorePlatform { @override Future remove(String key) async { - var preferences = await _readPreferences(); + final Map preferences = await _readPreferences(); preferences.remove(key); return _writePreferences(preferences); } @override Future setValue(String valueType, String key, Object value) async { - var preferences = await _readPreferences(); + final Map preferences = await _readPreferences(); preferences[key] = value; return _writePreferences(preferences); } diff --git a/packages/shared_preferences/shared_preferences_windows/pubspec.yaml b/packages/shared_preferences/shared_preferences_windows/pubspec.yaml index 87b685f6d0bc..76b3c74628b4 100644 --- a/packages/shared_preferences/shared_preferences_windows/pubspec.yaml +++ b/packages/shared_preferences/shared_preferences_windows/pubspec.yaml @@ -17,9 +17,9 @@ flutter: pluginClass: none dependencies: + file: ^6.0.0 flutter: sdk: flutter - file: ^6.0.0 meta: ^1.3.0 path: ^1.8.0 path_provider_platform_interface: ^2.0.0 diff --git a/packages/shared_preferences/shared_preferences_windows/test/shared_preferences_windows_test.dart b/packages/shared_preferences/shared_preferences_windows/test/shared_preferences_windows_test.dart index 6bb21b814e07..0c47e9865765 100644 --- a/packages/shared_preferences/shared_preferences_windows/test/shared_preferences_windows_test.dart +++ b/packages/shared_preferences/shared_preferences_windows/test/shared_preferences_windows_test.dart @@ -20,11 +20,11 @@ void main() { }); Future _getFilePath() async { - final directory = await pathProvider.getApplicationSupportPath(); + final String? directory = await pathProvider.getApplicationSupportPath(); return path.join(directory!, 'shared_preferences.json'); } - _writeTestFile(String value) async { + Future _writeTestFile(String value) async { fileSystem.file(await _getFilePath()) ..createSync(recursive: true) ..writeAsStringSync(value); @@ -35,7 +35,7 @@ void main() { } SharedPreferencesWindows _getPreferences() { - var prefs = SharedPreferencesWindows(); + final SharedPreferencesWindows prefs = SharedPreferencesWindows(); prefs.fs = fileSystem; prefs.pathProvider = pathProvider; return prefs; @@ -49,9 +49,9 @@ void main() { test('getAll', () async { await _writeTestFile('{"key1": "one", "key2": 2}'); - var prefs = _getPreferences(); + final SharedPreferencesWindows prefs = _getPreferences(); - var values = await prefs.getAll(); + final Map values = await prefs.getAll(); expect(values, hasLength(2)); expect(values['key1'], 'one'); expect(values['key2'], 2); @@ -59,7 +59,7 @@ void main() { test('remove', () async { await _writeTestFile('{"key1":"one","key2":2}'); - var prefs = _getPreferences(); + final SharedPreferencesWindows prefs = _getPreferences(); await prefs.remove('key2'); @@ -68,7 +68,7 @@ void main() { test('setValue', () async { await _writeTestFile('{}'); - var prefs = _getPreferences(); + final SharedPreferencesWindows prefs = _getPreferences(); await prefs.setValue('', 'key1', 'one'); await prefs.setValue('', 'key2', 2); @@ -78,7 +78,7 @@ void main() { test('clear', () async { await _writeTestFile('{"key1":"one","key2":2}'); - var prefs = _getPreferences(); + final SharedPreferencesWindows prefs = _getPreferences(); await prefs.clear(); expect(await _readTestFile(), '{}'); @@ -92,6 +92,7 @@ void main() { /// path it returns is a root path that does not actually exist on Windows. class FakePathProviderWindows extends PathProviderPlatform implements PathProviderWindows { + @override late VersionInfoQuerier versionInfoQuerier; @override diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml index 71d8fb825add..3840d64f33f3 100644 --- a/script/configs/custom_analysis.yaml +++ b/script/configs/custom_analysis.yaml @@ -23,7 +23,6 @@ - ios_platform_images - local_auth - quick_actions -- shared_preferences - url_launcher - video_player - webview_flutter From 5eec1e61d40c77b1db8250c9e0a085e894c820a2 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Thu, 21 Oct 2021 12:38:50 -0700 Subject: [PATCH 360/364] [ci] Update macOS Cirrus image to Xcode 13 (#4429) In anticipation of flutter/flutter#91634 Flutter tool enforcement, and to use the latest Xcode and SDKs that our customers may be using, upgrade the macOS Cirrus image to Xcode 13. --- .cirrus.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index f223ce93dfd2..da354c5a9b22 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -47,7 +47,7 @@ macos_template: &MACOS_TEMPLATE # PRs on macOS. use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true' osx_instance: - image: big-sur-xcode-12.5 + image: big-sur-xcode-13 # Light-workload Linux tasks. # These use default machines, with fewer CPUs, to reduce pressure on the @@ -275,7 +275,7 @@ task: SIMCTL_CHILD_MAPS_API_KEY: ENCRYPTED[596a9f6bca436694625ac50851dc5da6b4d34cba8025f7db5bc9465142e8cd44e15f69e3507787753accebfc4910d550] create_simulator_script: - xcrun simctl list - - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-5 | xargs xcrun simctl boot + - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-15-0 | xargs xcrun simctl boot build_script: - ./script/tool_runner.sh build-examples --ios xcode_analyze_script: From 7df7a8f71a853913cb6efac5b4ec687f6a392fdb Mon Sep 17 00:00:00 2001 From: Pavel Shapovalov Date: Fri, 22 Oct 2021 00:03:04 +0300 Subject: [PATCH 361/364] [google_sign_in] add serverAuthCode to GoogleSignInAccount (#4180) --- .../google_sign_in/google_sign_in/CHANGELOG.md | 4 ++++ .../google_sign_in/lib/google_sign_in.dart | 11 ++++++++++- .../google_sign_in/lib/src/common.dart | 3 +++ .../google_sign_in/lib/testing.dart | 5 +++++ .../google_sign_in/google_sign_in/pubspec.yaml | 4 ++-- .../google_sign_in/test/google_sign_in_test.dart | 15 +++++++++------ 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 6107560ce610..6f4ccebf8dc3 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.2.0 + +* Add `GoogleSignInAccount.serverAuthCode`. Mark `GoogleSignInAuthentication.serverAuthCode` as deprecated. + ## 5.1.1 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. diff --git a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart index 04d60fbc7d21..e104093c69bc 100644 --- a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart +++ b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart @@ -28,6 +28,7 @@ class GoogleSignInAuthentication { String? get accessToken => _data.accessToken; /// Server auth code used to access Google Login + @Deprecated('Use the `GoogleSignInAccount.serverAuthCode` property instead') String? get serverAuthCode => _data.serverAuthCode; @override @@ -44,6 +45,7 @@ class GoogleSignInAccount implements GoogleIdentity { email = data.email, id = data.id, photoUrl = data.photoUrl, + serverAuthCode = data.serverAuthCode, _idToken = data.idToken { assert(id != null); } @@ -68,6 +70,9 @@ class GoogleSignInAccount implements GoogleIdentity { @override final String? photoUrl; + @override + final String? serverAuthCode; + final String? _idToken; final GoogleSignIn _googleSignIn; @@ -97,6 +102,7 @@ class GoogleSignInAccount implements GoogleIdentity { if (response.idToken == null) { response.idToken = _idToken; } + return GoogleSignInAuthentication._(response); } @@ -132,11 +138,13 @@ class GoogleSignInAccount implements GoogleIdentity { email == otherAccount.email && id == otherAccount.id && photoUrl == otherAccount.photoUrl && + serverAuthCode == otherAccount.serverAuthCode && _idToken == otherAccount._idToken; } @override - int get hashCode => hashValues(displayName, email, id, photoUrl, _idToken); + int get hashCode => + hashValues(displayName, email, id, photoUrl, _idToken, serverAuthCode); @override String toString() { @@ -145,6 +153,7 @@ class GoogleSignInAccount implements GoogleIdentity { 'email': email, 'id': id, 'photoUrl': photoUrl, + 'serverAuthCode': serverAuthCode }; return 'GoogleSignInAccount:$data'; } diff --git a/packages/google_sign_in/google_sign_in/lib/src/common.dart b/packages/google_sign_in/google_sign_in/lib/src/common.dart index 068403e74629..8a1d4dcb354f 100644 --- a/packages/google_sign_in/google_sign_in/lib/src/common.dart +++ b/packages/google_sign_in/google_sign_in/lib/src/common.dart @@ -34,4 +34,7 @@ abstract class GoogleIdentity { /// /// Not guaranteed to be present for all users, even when configured. String? get photoUrl; + + /// Server auth code used to access Google Login + String? get serverAuthCode; } diff --git a/packages/google_sign_in/google_sign_in/lib/testing.dart b/packages/google_sign_in/google_sign_in/lib/testing.dart index c4d2da3089a5..e519b34b199a 100644 --- a/packages/google_sign_in/google_sign_in/lib/testing.dart +++ b/packages/google_sign_in/google_sign_in/lib/testing.dart @@ -67,6 +67,7 @@ class FakeUser { this.email, this.displayName, this.photoUrl, + this.serverAuthCode, this.idToken, this.accessToken, }); @@ -83,6 +84,9 @@ class FakeUser { /// Will be converted into [GoogleSignInUserData.photoUrl]. final String? photoUrl; + /// Will be converted into [GoogleSignInUserData.serverAuthCode]. + final String? serverAuthCode; + /// Will be converted into [GoogleSignInTokenData.idToken]. final String? idToken; @@ -94,6 +98,7 @@ class FakeUser { 'email': email, 'displayName': displayName, 'photoUrl': photoUrl, + 'serverAuthCode': serverAuthCode, 'idToken': idToken, }; } diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index aa0a686776fb..91114c6b0491 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.1.1 +version: 5.2.0 environment: sdk: ">=2.14.0 <3.0.0" @@ -23,7 +23,7 @@ flutter: dependencies: flutter: sdk: flutter - google_sign_in_platform_interface: ^2.0.1 + google_sign_in_platform_interface: ^2.1.0 google_sign_in_web: ^0.10.0 meta: ^1.3.0 diff --git a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart index 444edc4336ce..76bc2d600541 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart @@ -23,6 +23,7 @@ void main() { "id": "8162538176523816253123", "photoUrl": "https://lh5.googleusercontent.com/photo.jpg", "displayName": "John Doe", + "serverAuthCode": "789" }; const Map kDefaultResponses = { @@ -350,7 +351,8 @@ void main() { expect(auth.accessToken, '456'); expect(auth.idToken, '123'); - expect(auth.serverAuthCode, '789'); + // fix deprecated_member_use_from_same_package + // expect(auth.serverAuthCode, '789'); expect( log, [ @@ -382,11 +384,11 @@ void main() { group('GoogleSignIn with fake backend', () { const FakeUser kUserData = FakeUser( - id: "8162538176523816253123", - displayName: "John Doe", - email: "john.doe@gmail.com", - photoUrl: "https://lh5.googleusercontent.com/photo.jpg", - ); + id: "8162538176523816253123", + displayName: "John Doe", + email: "john.doe@gmail.com", + photoUrl: "https://lh5.googleusercontent.com/photo.jpg", + serverAuthCode: '789'); late GoogleSignIn googleSignIn; @@ -411,6 +413,7 @@ void main() { expect(user.email, equals(kUserData.email)); expect(user.id, equals(kUserData.id)); expect(user.photoUrl, equals(kUserData.photoUrl)); + expect(user.serverAuthCode, equals(kUserData.serverAuthCode)); await googleSignIn.disconnect(); expect(googleSignIn.currentUser, isNull); From 99fbfbd56fe4075a5622e874864fabbbef1c3881 Mon Sep 17 00:00:00 2001 From: Wyte Krongapiradee Date: Fri, 22 Oct 2021 05:08:07 +0800 Subject: [PATCH 362/364] [webview] Fix typos in the README (#4249) --- packages/webview_flutter/webview_flutter/CHANGELOG.md | 4 ++++ packages/webview_flutter/webview_flutter/README.md | 4 ++-- packages/webview_flutter/webview_flutter/pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 6724b43476ff..f599889c0d5e 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.2 + +* Fix typos in the README. + ## 2.1.1 * Fixed `_CastError` that was thrown when running the example App. diff --git a/packages/webview_flutter/webview_flutter/README.md b/packages/webview_flutter/webview_flutter/README.md index a1a98901affb..de475ad9acd7 100644 --- a/packages/webview_flutter/webview_flutter/README.md +++ b/packages/webview_flutter/webview_flutter/README.md @@ -23,8 +23,8 @@ Here are some points to consider when choosing between the two: * *Hybrid composition* mode has a built-in keyboard support while *Virtual displays* mode has multiple [keyboard issues](https://github.com/flutter/flutter/issues?q=is%3Aopen+label%3Avd-only+label%3A%22p%3A+webview-keyboard%22) -* *Hybrid composition* mode requires Android SKD 19+ while *Virtual displays* mode requires Android SDK 20+ -* *Hybrid composition* mode has [performence limitations](https://flutter.dev/docs/development/platform-integration/platform-views#performance) when working on Android versions prior to Android 10 while *Virtual displays* is performant on all supported Android versions +* *Hybrid composition* mode requires Android SDK 19+ while *Virtual displays* mode requires Android SDK 20+ +* *Hybrid composition* mode has [performance limitations](https://flutter.dev/docs/development/platform-integration/platform-views#performance) when working on Android versions prior to Android 10 while *Virtual displays* is performant on all supported Android versions | | Hybrid composition | Virtual displays | | --------------------------- | ------------------- | ---------------- | diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml index 4206789cb1b3..dabfe0d0d14a 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 -version: 2.1.1 +version: 2.1.2 environment: sdk: ">=2.14.0 <3.0.0" From 489c913f4eb78dfe27d0cdbfa8df77add544705e Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Thu, 21 Oct 2021 16:58:03 -0700 Subject: [PATCH 363/364] [google_sign_in] remove the commented out code in tests (#4442) --- .../google_sign_in/google_sign_in/test/google_sign_in_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart index 76bc2d600541..0a019a2ffae5 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart @@ -351,8 +351,6 @@ void main() { expect(auth.accessToken, '456'); expect(auth.idToken, '123'); - // fix deprecated_member_use_from_same_package - // expect(auth.serverAuthCode, '789'); expect( log, [ From 4b6b6b24c7a5c635d256c6556f63fbe0b755a825 Mon Sep 17 00:00:00 2001 From: Emmanuel Garcia Date: Thu, 21 Oct 2021 17:17:18 -0700 Subject: [PATCH 364/364] Use OpenJDK 11 in CI jobs (#4419) --- .ci/Dockerfile | 4 ---- .../connectivity/example/android/app/build.gradle | 6 +++--- .../android/app/gradle/wrapper/gradle-wrapper.properties | 5 ----- .../connectivity/connectivity/example/android/build.gradle | 2 +- .../android/gradle/wrapper/gradle-wrapper.properties | 2 +- .../video_player/example/android/app/build.gradle | 6 +++--- .../video_player/video_player/example/android/build.gradle | 2 +- .../android/gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 10 insertions(+), 19 deletions(-) delete mode 100644 packages/connectivity/connectivity/example/android/app/gradle/wrapper/gradle-wrapper.properties diff --git a/.ci/Dockerfile b/.ci/Dockerfile index a3deb6948d90..60f82eb005ed 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -5,10 +5,6 @@ FROM cirrusci/flutter:2.2.2 RUN apt-get update -y -# Required by Roboeletric and the Android SDK. -RUN apt-get install -y openjdk-8-jdk -ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 - RUN apt-get install -y --no-install-recommends gnupg # Add repo for gcloud sdk and install it diff --git a/packages/connectivity/connectivity/example/android/app/build.gradle b/packages/connectivity/connectivity/example/android/app/build.gradle index 64f3d0626bf4..99e360558af8 100644 --- a/packages/connectivity/connectivity/example/android/app/build.gradle +++ b/packages/connectivity/connectivity/example/android/app/build.gradle @@ -52,9 +52,9 @@ flutter { } dependencies { - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13' + testImplementation 'org.robolectric:robolectric:4.4' + testImplementation 'org.mockito:mockito-core:3.5.13' androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - testImplementation 'org.robolectric:robolectric:3.8' - testImplementation 'org.mockito:mockito-core:3.5.13' } diff --git a/packages/connectivity/connectivity/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/connectivity/connectivity/example/android/app/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 9a4163a4f5ee..000000000000 --- a/packages/connectivity/connectivity/example/android/app/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/packages/connectivity/connectivity/example/android/build.gradle b/packages/connectivity/connectivity/example/android/build.gradle index 456d020f6e2c..c21bff8e0a2f 100644 --- a/packages/connectivity/connectivity/example/android/build.gradle +++ b/packages/connectivity/connectivity/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:7.0.1' } } diff --git a/packages/connectivity/connectivity/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/connectivity/connectivity/example/android/gradle/wrapper/gradle-wrapper.properties index 01a286e96a21..297f2fec363f 100644 --- a/packages/connectivity/connectivity/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/connectivity/connectivity/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip diff --git a/packages/video_player/video_player/example/android/app/build.gradle b/packages/video_player/video_player/example/android/app/build.gradle index 0d1d5031ef4f..5f1f3afa6352 100644 --- a/packages/video_player/video_player/example/android/app/build.gradle +++ b/packages/video_player/video_player/example/android/app/build.gradle @@ -58,9 +58,9 @@ flutter { } dependencies { - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13' + testImplementation 'org.robolectric:robolectric:4.4' + testImplementation 'org.mockito:mockito-core:3.5.13' androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - testImplementation 'org.robolectric:robolectric:3.8' - testImplementation 'org.mockito:mockito-core:3.5.13' } diff --git a/packages/video_player/video_player/example/android/build.gradle b/packages/video_player/video_player/example/android/build.gradle index 456d020f6e2c..c21bff8e0a2f 100644 --- a/packages/video_player/video_player/example/android/build.gradle +++ b/packages/video_player/video_player/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:7.0.1' } } diff --git a/packages/video_player/video_player/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/video_player/video_player/example/android/gradle/wrapper/gradle-wrapper.properties index 296b146b7318..b8793d3c0d69 100644 --- a/packages/video_player/video_player/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/video_player/video_player/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip